diff --git a/AGENTS.md b/AGENTS.md index 8e7ff342b..35355dfef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,12 @@ obj.b const { a, b } = obj ``` +### Imports + +- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`. +- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`. +- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`. + ### Variables Prefer `const` over `let`. Use ternaries or early returns instead of reassignment. diff --git a/bun.lock b/bun.lock index fe206af0e..6367e3417 100644 --- a/bun.lock +++ b/bun.lock @@ -221,8 +221,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:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -230,6 +232,7 @@ "@opentelemetry/sdk-trace-base": "2.6.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", @@ -250,6 +253,7 @@ "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", "@types/semver": "catalog:", + "drizzle-kit": "catalog:", }, }, "packages/desktop": { diff --git a/packages/opencode/drizzle.config.ts b/packages/core/drizzle.config.ts similarity index 100% rename from packages/opencode/drizzle.config.ts rename to packages/core/drizzle.config.ts diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql b/packages/core/migration/20260127222353_familiar_lady_ursula/migration.sql similarity index 100% rename from packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql rename to packages/core/migration/20260127222353_familiar_lady_ursula/migration.sql diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json b/packages/core/migration/20260127222353_familiar_lady_ursula/snapshot.json similarity index 100% rename from packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json rename to packages/core/migration/20260127222353_familiar_lady_ursula/snapshot.json diff --git a/packages/opencode/migration/20260211171708_add_project_commands/migration.sql b/packages/core/migration/20260211171708_add_project_commands/migration.sql similarity index 100% rename from packages/opencode/migration/20260211171708_add_project_commands/migration.sql rename to packages/core/migration/20260211171708_add_project_commands/migration.sql diff --git a/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json b/packages/core/migration/20260211171708_add_project_commands/snapshot.json similarity index 100% rename from packages/opencode/migration/20260211171708_add_project_commands/snapshot.json rename to packages/core/migration/20260211171708_add_project_commands/snapshot.json diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql b/packages/core/migration/20260213144116_wakeful_the_professor/migration.sql similarity index 100% rename from packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql rename to packages/core/migration/20260213144116_wakeful_the_professor/migration.sql diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json b/packages/core/migration/20260213144116_wakeful_the_professor/snapshot.json similarity index 100% rename from packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json rename to packages/core/migration/20260213144116_wakeful_the_professor/snapshot.json diff --git a/packages/opencode/migration/20260225215848_workspace/migration.sql b/packages/core/migration/20260225215848_workspace/migration.sql similarity index 100% rename from packages/opencode/migration/20260225215848_workspace/migration.sql rename to packages/core/migration/20260225215848_workspace/migration.sql diff --git a/packages/opencode/migration/20260225215848_workspace/snapshot.json b/packages/core/migration/20260225215848_workspace/snapshot.json similarity index 100% rename from packages/opencode/migration/20260225215848_workspace/snapshot.json rename to packages/core/migration/20260225215848_workspace/snapshot.json diff --git a/packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql b/packages/core/migration/20260227213759_add_session_workspace_id/migration.sql similarity index 100% rename from packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql rename to packages/core/migration/20260227213759_add_session_workspace_id/migration.sql diff --git a/packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json b/packages/core/migration/20260227213759_add_session_workspace_id/snapshot.json similarity index 100% rename from packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json rename to packages/core/migration/20260227213759_add_session_workspace_id/snapshot.json diff --git a/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql b/packages/core/migration/20260228203230_blue_harpoon/migration.sql similarity index 100% rename from packages/opencode/migration/20260228203230_blue_harpoon/migration.sql rename to packages/core/migration/20260228203230_blue_harpoon/migration.sql diff --git a/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json b/packages/core/migration/20260228203230_blue_harpoon/snapshot.json similarity index 100% rename from packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json rename to packages/core/migration/20260228203230_blue_harpoon/snapshot.json diff --git a/packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql b/packages/core/migration/20260303231226_add_workspace_fields/migration.sql similarity index 100% rename from packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql rename to packages/core/migration/20260303231226_add_workspace_fields/migration.sql diff --git a/packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json b/packages/core/migration/20260303231226_add_workspace_fields/snapshot.json similarity index 100% rename from packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json rename to packages/core/migration/20260303231226_add_workspace_fields/snapshot.json diff --git a/packages/opencode/migration/20260309230000_move_org_to_state/migration.sql b/packages/core/migration/20260309230000_move_org_to_state/migration.sql similarity index 100% rename from packages/opencode/migration/20260309230000_move_org_to_state/migration.sql rename to packages/core/migration/20260309230000_move_org_to_state/migration.sql diff --git a/packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json b/packages/core/migration/20260309230000_move_org_to_state/snapshot.json similarity index 100% rename from packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json rename to packages/core/migration/20260309230000_move_org_to_state/snapshot.json diff --git a/packages/opencode/migration/20260312043431_session_message_cursor/migration.sql b/packages/core/migration/20260312043431_session_message_cursor/migration.sql similarity index 100% rename from packages/opencode/migration/20260312043431_session_message_cursor/migration.sql rename to packages/core/migration/20260312043431_session_message_cursor/migration.sql diff --git a/packages/opencode/migration/20260312043431_session_message_cursor/snapshot.json b/packages/core/migration/20260312043431_session_message_cursor/snapshot.json similarity index 100% rename from packages/opencode/migration/20260312043431_session_message_cursor/snapshot.json rename to packages/core/migration/20260312043431_session_message_cursor/snapshot.json diff --git a/packages/opencode/migration/20260323234822_events/migration.sql b/packages/core/migration/20260323234822_events/migration.sql similarity index 100% rename from packages/opencode/migration/20260323234822_events/migration.sql rename to packages/core/migration/20260323234822_events/migration.sql diff --git a/packages/opencode/migration/20260323234822_events/snapshot.json b/packages/core/migration/20260323234822_events/snapshot.json similarity index 100% rename from packages/opencode/migration/20260323234822_events/snapshot.json rename to packages/core/migration/20260323234822_events/snapshot.json diff --git a/packages/opencode/migration/20260410174513_workspace-name/migration.sql b/packages/core/migration/20260410174513_workspace-name/migration.sql similarity index 100% rename from packages/opencode/migration/20260410174513_workspace-name/migration.sql rename to packages/core/migration/20260410174513_workspace-name/migration.sql diff --git a/packages/opencode/migration/20260410174513_workspace-name/snapshot.json b/packages/core/migration/20260410174513_workspace-name/snapshot.json similarity index 100% rename from packages/opencode/migration/20260410174513_workspace-name/snapshot.json rename to packages/core/migration/20260410174513_workspace-name/snapshot.json diff --git a/packages/opencode/migration/20260413175956_chief_energizer/migration.sql b/packages/core/migration/20260413175956_chief_energizer/migration.sql similarity index 100% rename from packages/opencode/migration/20260413175956_chief_energizer/migration.sql rename to packages/core/migration/20260413175956_chief_energizer/migration.sql diff --git a/packages/opencode/migration/20260413175956_chief_energizer/snapshot.json b/packages/core/migration/20260413175956_chief_energizer/snapshot.json similarity index 100% rename from packages/opencode/migration/20260413175956_chief_energizer/snapshot.json rename to packages/core/migration/20260413175956_chief_energizer/snapshot.json diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql b/packages/core/migration/20260423070820_add_icon_url_override/migration.sql similarity index 100% rename from packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql rename to packages/core/migration/20260423070820_add_icon_url_override/migration.sql diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json b/packages/core/migration/20260423070820_add_icon_url_override/snapshot.json similarity index 100% rename from packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json rename to packages/core/migration/20260423070820_add_icon_url_override/snapshot.json diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql b/packages/core/migration/20260427172553_slow_nightmare/migration.sql similarity index 100% rename from packages/opencode/migration/20260427172553_slow_nightmare/migration.sql rename to packages/core/migration/20260427172553_slow_nightmare/migration.sql diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/core/migration/20260427172553_slow_nightmare/snapshot.json similarity index 100% rename from packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json rename to packages/core/migration/20260427172553_slow_nightmare/snapshot.json diff --git a/packages/opencode/migration/20260428004200_add_session_path/migration.sql b/packages/core/migration/20260428004200_add_session_path/migration.sql similarity index 100% rename from packages/opencode/migration/20260428004200_add_session_path/migration.sql rename to packages/core/migration/20260428004200_add_session_path/migration.sql diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/core/migration/20260428004200_add_session_path/snapshot.json similarity index 100% rename from packages/opencode/migration/20260428004200_add_session_path/snapshot.json rename to packages/core/migration/20260428004200_add_session_path/snapshot.json diff --git a/packages/opencode/migration/20260501142318_next_venus/migration.sql b/packages/core/migration/20260501142318_next_venus/migration.sql similarity index 100% rename from packages/opencode/migration/20260501142318_next_venus/migration.sql rename to packages/core/migration/20260501142318_next_venus/migration.sql diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/core/migration/20260501142318_next_venus/snapshot.json similarity index 100% rename from packages/opencode/migration/20260501142318_next_venus/snapshot.json rename to packages/core/migration/20260501142318_next_venus/snapshot.json diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql b/packages/core/migration/20260504145000_add_sync_owner/migration.sql similarity index 100% rename from packages/opencode/migration/20260504145000_add_sync_owner/migration.sql rename to packages/core/migration/20260504145000_add_sync_owner/migration.sql diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json b/packages/core/migration/20260504145000_add_sync_owner/snapshot.json similarity index 100% rename from packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json rename to packages/core/migration/20260504145000_add_sync_owner/snapshot.json diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/core/migration/20260507164347_add_workspace_time/migration.sql similarity index 100% rename from packages/opencode/migration/20260507164347_add_workspace_time/migration.sql rename to packages/core/migration/20260507164347_add_workspace_time/migration.sql diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json b/packages/core/migration/20260507164347_add_workspace_time/snapshot.json similarity index 100% rename from packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json rename to packages/core/migration/20260507164347_add_workspace_time/snapshot.json diff --git a/packages/opencode/migration/20260510033149_session_usage/migration.sql b/packages/core/migration/20260510033149_session_usage/migration.sql similarity index 100% rename from packages/opencode/migration/20260510033149_session_usage/migration.sql rename to packages/core/migration/20260510033149_session_usage/migration.sql diff --git a/packages/opencode/migration/20260510033149_session_usage/snapshot.json b/packages/core/migration/20260510033149_session_usage/snapshot.json similarity index 100% rename from packages/opencode/migration/20260510033149_session_usage/snapshot.json rename to packages/core/migration/20260510033149_session_usage/snapshot.json diff --git a/packages/opencode/migration/20260511000411_data_migration_state/migration.sql b/packages/core/migration/20260511000411_data_migration_state/migration.sql similarity index 100% rename from packages/opencode/migration/20260511000411_data_migration_state/migration.sql rename to packages/core/migration/20260511000411_data_migration_state/migration.sql diff --git a/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json b/packages/core/migration/20260511000411_data_migration_state/snapshot.json similarity index 100% rename from packages/opencode/migration/20260511000411_data_migration_state/snapshot.json rename to packages/core/migration/20260511000411_data_migration_state/snapshot.json diff --git a/packages/core/package.json b/packages/core/package.json index a2c7eadb3..0be66e576 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,6 +6,8 @@ "license": "MIT", "private": true, "scripts": { + "db": "bun drizzle-kit", + "migration": "bun run script/migration.ts", "test": "bun test", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" @@ -23,7 +25,8 @@ "@types/cross-spawn": "catalog:", "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", - "@types/semver": "catalog:" + "@types/semver": "catalog:", + "drizzle-kit": "catalog:" }, "dependencies": { "@ai-sdk/alibaba": "1.0.17", diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts new file mode 100644 index 000000000..df83857c6 --- /dev/null +++ b/packages/core/script/migration.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import path from "path" + +const root = path.resolve(import.meta.dirname, "../../..") +const sqlDir = path.join(root, "packages/core/migration") +const tsDir = path.join(root, "packages/core/src/database/migration") +const registry = path.join(root, "packages/core/src/database/migration.gen.ts") + +await $`bun drizzle-kit generate`.cwd(path.join(root, "packages/core")) + +const sqlMigrations = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: sqlDir }))) + .map((file) => file.split("/")[0]) + .filter((name) => name !== undefined) + .sort() + +for (const name of sqlMigrations) { + if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue + await Bun.write(path.join(tsDir, `${name}.ts`), renderMigration(name, await Bun.file(path.join(sqlDir, name, "migration.sql")).text())) +} + +await Bun.write(registry, renderRegistry(sqlMigrations)) + +function renderMigration(name: string, sql: string) { + return `import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: ${JSON.stringify(name)}, + up(tx) { + return Effect.gen(function* () { +${sql + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0) + .map(renderRun) + .join("\n")} + }) + }, +} satisfies DatabaseMigration.Migration +` +} + +function renderRun(statement: string) { + const lines = statement.replaceAll("\t", " ").split("\n") + if (lines.length === 1) return ` yield* tx.run(\`${escapeTemplate(lines[0])}\`)` + return ` yield* tx.run(\`\n${lines.map((line) => ` ${escapeTemplate(line)}`).join("\n")}\n \`)` +} + +function escapeTemplate(line: string) { + return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${") +} + +function renderRegistry(names: string[]) { + return `import type { DatabaseMigration } from "./migration" + +export const migrations = (await Promise.all([ +${names.map((name) => ` import("./migration/${name}"),`).join("\n")} +])).map((module) => module.default) satisfies DatabaseMigration.Migration[] +` +} diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts index a124a9a15..4bd11b4e1 100644 --- a/packages/core/src/account.ts +++ b/packages/core/src/account.ts @@ -1,319 +1 @@ -import path from "path" -import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" -import { Identifier } from "./util/identifier" -import { NonNegativeInt, withStatics } from "./schema" -import { Global } from "./global" -import { AppFileSystem } from "./filesystem" -import { EventV2 } from "./event" - -export const ID = Schema.String.pipe( - Schema.brand("AccountV2.ID"), - withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), -) -export type ID = typeof ID.Type - -export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) -export type ServiceID = typeof ServiceID.Type - -export class OAuthCredential extends Schema.Class("AccountV2.OAuthCredential")({ - type: Schema.Literal("oauth"), - refresh: Schema.String, - access: Schema.String, - expires: NonNegativeInt, -}) {} - -export class ApiKeyCredential extends Schema.Class("AccountV2.ApiKeyCredential")({ - type: Schema.Literal("api"), - key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) {} - -export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ - identifier: "AccountV2.Credential", - }) -export type Credential = Schema.Schema.Type - -export class Info extends Schema.Class("AccountV2.Info")({ - id: ID, - serviceID: ServiceID, - description: Schema.String, - credential: Credential, -}) {} - -export class FileWriteError extends Schema.TaggedErrorClass()("AccountV2.FileWriteError", { - operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), - cause: Schema.Defect, -}) {} - -export type Error = FileWriteError - -export const Event = { - Added: EventV2.define({ - type: "account.added", - schema: { - account: Info, - }, - }), - Removed: EventV2.define({ - type: "account.removed", - schema: { - account: Info, - }, - }), - Switched: EventV2.define({ - type: "account.switched", - schema: { - serviceID: ServiceID, - from: Schema.optional(ID), - to: Schema.optional(ID), - }, - }), -} - -interface Writable { - version: 2 - accounts: Record - active: Record -} - -const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) - -function migrate(old: Record): Writable { - const accounts: Record = {} - const active: Record = {} - for (const [serviceID, value] of Object.entries(old)) { - const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) - const parsed = (decoded as Record)[serviceID] - if (!parsed) continue - const id = Identifier.ascending() - const account = ID.make(id) - const brandedServiceID = ServiceID.make(serviceID) - accounts[id] = new Info({ - id: account, - serviceID: brandedServiceID, - description: "default", - credential: parsed, - }) - active[brandedServiceID] = account - } - return { version: 2, accounts, active } -} - -export interface Interface { - readonly get: (id: ID) => Effect.Effect - readonly all: () => Effect.Effect - readonly create: (input: { - serviceID: ServiceID - credential: Credential - description?: string - }) => Effect.Effect - readonly update: (id: ID, updates: Partial>) => Effect.Effect - readonly remove: (id: ID) => Effect.Effect - readonly activate: (id: ID) => Effect.Effect - readonly active: (serviceID: ServiceID) => Effect.Effect - readonly forService: (serviceID: ServiceID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Account") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fsys = yield* AppFileSystem.Service - const global = yield* Global.Service - const events = yield* EventV2.Service - const file = path.join(global.data, "account.json") - const legacyFile = path.join(global.data, "auth.json") - - const writeMigrated = Effect.fnUntraced(function* (raw: Record) { - const migrated = migrate(raw) - yield* fsys - .writeJson(file, migrated, 0o600) - .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause }))) - return migrated - }) - - const parseAuthContent = () => { - try { - return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") - } catch {} - } - - const load: () => Effect.Effect = Effect.fnUntraced(function* () { - if (process.env.OPENCODE_AUTH_CONTENT) { - const raw = parseAuthContent() - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - return { version: 2, accounts: {}, active: {} } - } - - const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) - if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) - - const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) - - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - - return { version: 2, accounts: {}, active: {} } - }) - - const write = (data: Writable) => - fsys - .writeJson(file, data, 0o600) - .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause }))) - - const state = SynchronizedRef.makeUnsafe( - yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))), - ) - - const activate = Effect.fn("AccountV2.activate")(function* (id: ID) { - const data = yield* SynchronizedRef.get(state) - const account = data.accounts[id] - if (!account) return - const activated = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const nextAccount = data.accounts[id] - if (!nextAccount) return [undefined, data] as const - - const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } } - yield* write(next) - return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const - }), - ) - if (activated) yield* events.publish(Event.Switched, activated) - }) - - const result: Interface = { - get: Effect.fn("AccountV2.get")(function* (id) { - return (yield* SynchronizedRef.get(state)).accounts[id] - }), - - all: Effect.fn("AccountV2.all")(function* () { - return Object.values((yield* SynchronizedRef.get(state)).accounts) - }), - - active: Effect.fn("AccountV2.active")(function* (serviceID) { - const data = yield* SynchronizedRef.get(state) - return ( - data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) - ) - }), - - forService: Effect.fn("AccountV2.list")(function* (serviceID) { - return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) - }), - - create: Effect.fn("AccountV2.add")(function* (input) { - const id = ID.make(Identifier.ascending()) - const account = new Info({ - id, - serviceID: input.serviceID, - description: input.description ?? "default", - credential: input.credential, - }) - const added = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const next = { - ...data, - accounts: { ...data.accounts, [account.id]: account }, - active: { ...data.active, [account.serviceID]: account.id }, - } - - yield* write(next) - return [ - { - account, - switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id }, - }, - next, - ] as const - }), - ) - yield* events.publish(Event.Added, { account: added.account }) - yield* events.publish(Event.Switched, added.switched) - return added.account - }), - - update: Effect.fn("AccountV2.update")(function* (id, updates) { - const existing = (yield* SynchronizedRef.get(state)).accounts[id] - if (!existing) return - yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - if (!data.accounts[id]) return [undefined, data] as const - - const next = { - ...data, - accounts: { - ...data.accounts, - [id]: new Info({ - id, - serviceID: existing.serviceID, - description: updates.description ?? existing.description, - credential: updates.credential ?? existing.credential, - }), - }, - } - - yield* write(next) - return [undefined, next] as const - }), - ) - }), - - remove: Effect.fn("AccountV2.remove")(function* (id) { - const removed = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const accounts = { ...data.accounts } - const active = { ...data.active } - const removed = accounts[id] - if (!removed) return [undefined, data] as const - const wasActive = active[removed.serviceID] === id - delete accounts[id] - const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID) - if (wasActive) { - if (replacement) active[removed.serviceID] = replacement.id - else delete active[removed.serviceID] - } - - const next = { ...data, accounts, active } - yield* write(next) - return [ - { - account: removed, - switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined, - }, - next, - ] as const - }), - ) - if (removed) { - yield* events.publish(Event.Removed, { account: removed.account }) - if (removed.switched) yield* events.publish(Event.Switched, removed.switched) - } - }), - - activate, - } - - return Service.of(result) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Global.defaultLayer), - Layer.provide(EventV2.defaultLayer), -) - -export * as AccountV2 from "./account" +export * from "./account/index" diff --git a/packages/core/src/account/index.ts b/packages/core/src/account/index.ts new file mode 100644 index 000000000..1612cbf2e --- /dev/null +++ b/packages/core/src/account/index.ts @@ -0,0 +1,101 @@ +export * as AccountV2 from "." + +import { Schema } from "effect" +import type * as HttpClientError from "effect/unstable/http/HttpClientError" + +export const ID = Schema.String.pipe(Schema.brand("AccountID")) +export type ID = Schema.Schema.Type + +export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) +export type OrgID = Schema.Schema.Type + +export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) +export type AccessToken = Schema.Schema.Type + +export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) +export type RefreshToken = Schema.Schema.Type + +export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode")) +export type DeviceCode = Schema.Schema.Type + +export const UserCode = Schema.String.pipe(Schema.brand("UserCode")) +export type UserCode = Schema.Schema.Type + +export class Info extends Schema.Class("Account")({ + id: ID, + email: Schema.String, + url: Schema.String, + active_org_id: Schema.NullOr(OrgID), +}) {} + +export class Org extends Schema.Class("Org")({ + id: OrgID, + name: Schema.String, +}) {} + +export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", { + method: Schema.String, + url: Schema.String, + description: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), +}) { + static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError { + return new AccountTransportError({ + method: error.request.method, + url: error.request.url, + description: error.description, + cause: error.cause, + }) + } + + override get message(): string { + return [ + `Could not reach ${this.method} ${this.url}.`, + `This failed before the server returned an HTTP response.`, + this.description, + `Check your network, proxy, or VPN configuration and try again.`, + ] + .filter(Boolean) + .join("\n") + } +} + +export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError + +export class Login extends Schema.Class("Login")({ + code: DeviceCode, + user: UserCode, + url: Schema.String, + server: Schema.String, + expiry: Schema.Duration, + interval: Schema.Duration, +}) {} + +export class PollSuccess extends Schema.TaggedClass()("PollSuccess", { + email: Schema.String, +}) {} + +export class PollPending extends Schema.TaggedClass()("PollPending", {}) {} + +export class PollSlow extends Schema.TaggedClass()("PollSlow", {}) {} + +export class PollExpired extends Schema.TaggedClass()("PollExpired", {}) {} + +export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} + +export class PollError extends Schema.TaggedClass()("PollError", { + cause: Schema.Defect, +}) {} + +export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) +export type PollResult = Schema.Schema.Type diff --git a/packages/opencode/src/account/account.sql.ts b/packages/core/src/account/sql.ts similarity index 61% rename from packages/opencode/src/account/account.sql.ts rename to packages/core/src/account/sql.ts index 35bfd1e3e..1c0ae693e 100644 --- a/packages/opencode/src/account/account.sql.ts +++ b/packages/core/src/account/sql.ts @@ -1,14 +1,14 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" -import { type AccessToken, type AccountID, type OrgID, type RefreshToken } from "./schema" -import { Timestamps } from "../storage/schema.sql" +import { AccountV2 } from "." +import { Timestamps } from "../database/schema.sql" export const AccountTable = sqliteTable("account", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), email: text().notNull(), url: text().notNull(), - access_token: text().$type().notNull(), - refresh_token: text().$type().notNull(), + access_token: text().$type().notNull(), + refresh_token: text().$type().notNull(), token_expiry: integer(), ...Timestamps, }) @@ -16,9 +16,9 @@ export const AccountTable = sqliteTable("account", { export const AccountStateTable = sqliteTable("account_state", { id: integer().primaryKey(), active_account_id: text() - .$type() + .$type() .references(() => AccountTable.id, { onDelete: "set null" }), - active_org_id: text().$type(), + active_org_id: text().$type(), }) // LEGACY @@ -27,8 +27,8 @@ export const ControlAccountTable = sqliteTable( { email: text().notNull(), url: text().notNull(), - access_token: text().$type().notNull(), - refresh_token: text().$type().notNull(), + access_token: text().$type().notNull(), + refresh_token: text().$type().notNull(), token_expiry: integer(), active: integer({ mode: "boolean" }) .notNull() diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 000000000..916bef9d1 --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,326 @@ +export * as Auth from "./auth" + +import path from "path" +import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" +import { Identifier } from "./util/identifier" +import { NonNegativeInt, withStatics } from "./schema" +import { Global } from "./global" +import { AppFileSystem } from "./filesystem" +import { EventV2 } from "./event" + +export const ID = Schema.String.pipe( + Schema.brand("Auth.ID"), + withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) +export type ServiceID = typeof ServiceID.Type + +export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) +export type OrgID = typeof OrgID.Type +export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) +export type AccessToken = typeof AccessToken.Type +export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) +export type RefreshToken = typeof RefreshToken.Type + +export class OAuthCredential extends Schema.Class("Auth.OAuthCredential")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, +}) {} + +export class ApiKeyCredential extends Schema.Class("Auth.ApiKeyCredential")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ + identifier: "Auth.Credential", + }) +export type Credential = Schema.Schema.Type + +export class Info extends Schema.Class("Auth.Info")({ + id: ID, + serviceID: ServiceID, + description: Schema.String, + credential: Credential, +}) {} + +export class FileWriteError extends Schema.TaggedErrorClass()("Auth.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export type Error = FileWriteError + +export const Event = { + Added: EventV2.define({ + type: "account.added", + schema: { + account: Info, + }, + }), + Removed: EventV2.define({ + type: "account.removed", + schema: { + account: Info, + }, + }), + Switched: EventV2.define({ + type: "account.switched", + schema: { + serviceID: ServiceID, + from: Schema.optional(ID), + to: Schema.optional(ID), + }, + }), +} + +interface Writable { + version: 2 + accounts: Record + active: Record +} + +const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) + +function migrate(old: Record): Writable { + const accounts: Record = {} + const active: Record = {} + for (const [serviceID, value] of Object.entries(old)) { + const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) + const parsed = (decoded as Record)[serviceID] + if (!parsed) continue + const id = Identifier.ascending() + const account = ID.make(id) + const brandedServiceID = ServiceID.make(serviceID) + accounts[id] = new Info({ + id: account, + serviceID: brandedServiceID, + description: "default", + credential: parsed, + }) + active[brandedServiceID] = account + } + return { version: 2, accounts, active } +} + +export interface Interface { + readonly get: (id: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + serviceID: ServiceID + credential: Credential + description?: string + }) => Effect.Effect + readonly update: (id: ID, updates: Partial>) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect + readonly activate: (id: ID) => Effect.Effect + readonly active: (serviceID: ServiceID) => Effect.Effect + readonly forService: (serviceID: ServiceID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Account") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service + const events = yield* EventV2.Service + const file = path.join(global.data, "account.json") + const legacyFile = path.join(global.data, "auth.json") + + const writeMigrated = Effect.fnUntraced(function* (raw: Record) { + const migrated = migrate(raw) + yield* fsys + .writeJson(file, migrated, 0o600) + .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause }))) + return migrated + }) + + const parseAuthContent = () => { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") + } catch {} + } + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + const raw = parseAuthContent() + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + return { version: 2, accounts: {}, active: {} } + } + + const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) + if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + + return { version: 2, accounts: {}, active: {} } + }) + + const write = (data: Writable) => + fsys + .writeJson(file, data, 0o600) + .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause }))) + + const state = SynchronizedRef.makeUnsafe( + yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))), + ) + + const activate = Effect.fn("Auth.activate")(function* (id: ID) { + const data = yield* SynchronizedRef.get(state) + const account = data.accounts[id] + if (!account) return + const activated = yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const nextAccount = data.accounts[id] + if (!nextAccount) return [undefined, data] as const + + const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } } + yield* write(next) + return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const + }), + ) + if (activated) yield* events.publish(Event.Switched, activated) + }) + + const result: Interface = { + get: Effect.fn("Auth.get")(function* (id) { + return (yield* SynchronizedRef.get(state)).accounts[id] + }), + + all: Effect.fn("Auth.all")(function* () { + return Object.values((yield* SynchronizedRef.get(state)).accounts) + }), + + active: Effect.fn("Auth.active")(function* (serviceID) { + const data = yield* SynchronizedRef.get(state) + return ( + data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) + ) + }), + + forService: Effect.fn("Auth.list")(function* (serviceID) { + return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) + }), + + create: Effect.fn("Auth.add")(function* (input) { + const id = ID.make(Identifier.ascending()) + const account = new Info({ + id, + serviceID: input.serviceID, + description: input.description ?? "default", + credential: input.credential, + }) + const added = yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const next = { + ...data, + accounts: { ...data.accounts, [account.id]: account }, + active: { ...data.active, [account.serviceID]: account.id }, + } + + yield* write(next) + return [ + { + account, + switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id }, + }, + next, + ] as const + }), + ) + yield* events.publish(Event.Added, { account: added.account }) + yield* events.publish(Event.Switched, added.switched) + return added.account + }), + + update: Effect.fn("Auth.update")(function* (id, updates) { + const existing = (yield* SynchronizedRef.get(state)).accounts[id] + if (!existing) return + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + if (!data.accounts[id]) return [undefined, data] as const + + const next = { + ...data, + accounts: { + ...data.accounts, + [id]: new Info({ + id, + serviceID: existing.serviceID, + description: updates.description ?? existing.description, + credential: updates.credential ?? existing.credential, + }), + }, + } + + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + remove: Effect.fn("Auth.remove")(function* (id) { + const removed = yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const accounts = { ...data.accounts } + const active = { ...data.active } + const removed = accounts[id] + if (!removed) return [undefined, data] as const + const wasActive = active[removed.serviceID] === id + delete accounts[id] + const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID) + if (wasActive) { + if (replacement) active[removed.serviceID] = replacement.id + else delete active[removed.serviceID] + } + + const next = { ...data, accounts, active } + yield* write(next) + return [ + { + account: removed, + switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined, + }, + next, + ] as const + }), + ) + if (removed) { + yield* events.publish(Event.Removed, { account: removed.account }) + if (removed.switched) yield* events.publish(Event.Switched, removed.switched) + } + }), + + activate, + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.defaultLayer), + Layer.provide(EventV2.defaultLayer), +) diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/core/src/control-plane/workspace.sql.ts similarity index 66% rename from packages/opencode/src/control-plane/workspace.sql.ts rename to packages/core/src/control-plane/workspace.sql.ts index 1afaf7cbc..b7c7e1d6c 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/core/src/control-plane/workspace.sql.ts @@ -1,17 +1,17 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" -import { ProjectTable } from "../project/project.sql" -import type { ProjectID } from "../project/schema" -import type { WorkspaceID } from "./schema" +import { ProjectTable } from "../project/sql" +import { Project } from "../project" +import { WorkspaceV2 } from "../workspace" export const WorkspaceTable = sqliteTable("workspace", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), type: text().notNull(), name: text().notNull().default(""), branch: text(), directory: text(), extra: text({ mode: "json" }), project_id: text() - .$type() + .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), time_used: integer() diff --git a/packages/opencode/src/data-migration.sql.ts b/packages/core/src/data-migration.sql.ts similarity index 100% rename from packages/opencode/src/data-migration.sql.ts rename to packages/core/src/data-migration.sql.ts diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 36b49b65c..090a86ece 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -24,9 +24,7 @@ const layer = Layer.effect( 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 - + yield* DatabaseMigration.apply(db) return db }), diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts new file mode 100644 index 000000000..e5e143e83 --- /dev/null +++ b/packages/core/src/database/migration.gen.ts @@ -0,0 +1,24 @@ +import type { DatabaseMigration } from "./migration" + +export const migrations = (await Promise.all([ + import("./migration/20260127222353_familiar_lady_ursula"), + import("./migration/20260211171708_add_project_commands"), + import("./migration/20260213144116_wakeful_the_professor"), + import("./migration/20260225215848_workspace"), + import("./migration/20260227213759_add_session_workspace_id"), + import("./migration/20260228203230_blue_harpoon"), + import("./migration/20260303231226_add_workspace_fields"), + import("./migration/20260309230000_move_org_to_state"), + import("./migration/20260312043431_session_message_cursor"), + import("./migration/20260323234822_events"), + import("./migration/20260410174513_workspace-name"), + import("./migration/20260413175956_chief_energizer"), + import("./migration/20260423070820_add_icon_url_override"), + import("./migration/20260427172553_slow_nightmare"), + import("./migration/20260428004200_add_session_path"), + import("./migration/20260501142318_next_venus"), + import("./migration/20260504145000_add_sync_owner"), + import("./migration/20260507164347_add_workspace_time"), + import("./migration/20260510033149_session_usage"), + import("./migration/20260511000411_data_migration_state"), +])).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index e9575c865..4d018bc40 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -1,248 +1,48 @@ export * as DatabaseMigration from "./migration" -import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { sql } from "drizzle-orm" 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" +import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { migrations } from "./migration.gen" -export type SchemaAst = { - tables: Record +type Database = EffectDrizzleSqlite.EffectSQLiteDatabase +type Transaction = Parameters[0]>[0] + +export type Migration = { + id: string + up: (tx: Transaction) => Effect.Effect } -export type TableAst = { - name: string - columns: Record - indexes: Record +export function apply(db: Database) { + return applyOnly(db, migrations) } -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((table) => { - const current = actual.tables[table.name] - if (!current) { - return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)] +export function applyOnly(db: Database, input: Migration[]) { + return Effect.gen(function* () { + yield* db.run(sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`) + let completed = new Set((yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id)) + if (completed.size === 0) { + // Existing installs used Drizzle's migration journal. Seed the new + // journal once so TypeScript migrations don't replay old SQL. + if (yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)) { + yield* db.run(sql` + INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed) + SELECT name, ${Date.now()} + FROM ${sql.identifier("__drizzle_migrations")} + WHERE name IS NOT NULL + `) + completed = new Set((yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id)) + } } - 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)), + for (const migration of input) { + if (completed.has(migration.id)) continue + yield* db.transaction((tx) => + Effect.gen(function* () { + yield* migration.up(tx) + yield* tx.run(sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`) + }), + ) } }) } - -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('"', '""')}"` -} diff --git a/packages/core/src/database/migration/20260127222353_familiar_lady_ursula.ts b/packages/core/src/database/migration/20260127222353_familiar_lady_ursula.ts new file mode 100644 index 000000000..468a7103f --- /dev/null +++ b/packages/core/src/database/migration/20260127222353_familiar_lady_ursula.ts @@ -0,0 +1,107 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260127222353_familiar_lady_ursula", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`project\` ( + \`id\` text PRIMARY KEY, + \`worktree\` text NOT NULL, + \`vcs\` text, + \`name\` text, + \`icon_url\` text, + \`icon_color\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`time_initialized\` integer, + \`sandboxes\` text NOT NULL + ); + `) + yield* tx.run(` + CREATE TABLE \`message\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`part\` ( + \`id\` text PRIMARY KEY, + \`message_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`permission\` ( + \`project_id\` text PRIMARY KEY, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_permission_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session\` ( + \`id\` text PRIMARY KEY, + \`project_id\` text NOT NULL, + \`parent_id\` text, + \`slug\` text NOT NULL, + \`directory\` text NOT NULL, + \`title\` text NOT NULL, + \`version\` text NOT NULL, + \`share_url\` text, + \`summary_additions\` integer, + \`summary_deletions\` integer, + \`summary_files\` integer, + \`summary_diffs\` text, + \`revert\` text, + \`permission\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`time_compacting\` integer, + \`time_archived\` integer, + CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`todo\` ( + \`session_id\` text NOT NULL, + \`content\` text NOT NULL, + \`status\` text NOT NULL, + \`priority\` text NOT NULL, + \`position\` integer NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`), + CONSTRAINT \`fk_todo_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session_share\` ( + \`session_id\` text PRIMARY KEY, + \`id\` text NOT NULL, + \`secret\` text NOT NULL, + \`url\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE INDEX \`message_session_idx\` ON \`message\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`part_message_idx\` ON \`part\` (\`message_id\`);`) + yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`) + yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`) + yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260211171708_add_project_commands.ts b/packages/core/src/database/migration/20260211171708_add_project_commands.ts new file mode 100644 index 000000000..d31a533db --- /dev/null +++ b/packages/core/src/database/migration/20260211171708_add_project_commands.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260211171708_add_project_commands", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`project\` ADD \`commands\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260213144116_wakeful_the_professor.ts b/packages/core/src/database/migration/20260213144116_wakeful_the_professor.ts new file mode 100644 index 000000000..8077182d9 --- /dev/null +++ b/packages/core/src/database/migration/20260213144116_wakeful_the_professor.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260213144116_wakeful_the_professor", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`control_account\` ( + \`email\` text NOT NULL, + \`url\` text NOT NULL, + \`access_token\` text NOT NULL, + \`refresh_token\` text NOT NULL, + \`token_expiry\` integer, + \`active\` integer NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`control_account_pk\` PRIMARY KEY(\`email\`, \`url\`) + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260225215848_workspace.ts b/packages/core/src/database/migration/20260225215848_workspace.ts new file mode 100644 index 000000000..cc816951e --- /dev/null +++ b/packages/core/src/database/migration/20260225215848_workspace.ts @@ -0,0 +1,19 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260225215848_workspace", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`workspace\` ( + \`id\` text PRIMARY KEY, + \`branch\` text, + \`project_id\` text NOT NULL, + \`config\` text NOT NULL, + CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260227213759_add_session_workspace_id.ts b/packages/core/src/database/migration/20260227213759_add_session_workspace_id.ts new file mode 100644 index 000000000..430407156 --- /dev/null +++ b/packages/core/src/database/migration/20260227213759_add_session_workspace_id.ts @@ -0,0 +1,12 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260227213759_add_session_workspace_id", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`workspace_id\` text;`) + yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260228203230_blue_harpoon.ts b/packages/core/src/database/migration/20260228203230_blue_harpoon.ts new file mode 100644 index 000000000..83e2978f7 --- /dev/null +++ b/packages/core/src/database/migration/20260228203230_blue_harpoon.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260228203230_blue_harpoon", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`account\` ( + \`id\` text PRIMARY KEY, + \`email\` text NOT NULL, + \`url\` text NOT NULL, + \`access_token\` text NOT NULL, + \`refresh_token\` text NOT NULL, + \`token_expiry\` integer, + \`selected_org_id\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + yield* tx.run(` + CREATE TABLE \`account_state\` ( + \`id\` integer PRIMARY KEY NOT NULL, + \`active_account_id\` text, + FOREIGN KEY (\`active_account_id\`) REFERENCES \`account\`(\`id\`) ON UPDATE no action ON DELETE set null + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260303231226_add_workspace_fields.ts b/packages/core/src/database/migration/20260303231226_add_workspace_fields.ts new file mode 100644 index 000000000..380e9cc68 --- /dev/null +++ b/packages/core/src/database/migration/20260303231226_add_workspace_fields.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260303231226_add_workspace_fields", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`type\` text NOT NULL;`) + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`name\` text;`) + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`directory\` text;`) + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`extra\` text;`) + yield* tx.run(`ALTER TABLE \`workspace\` DROP COLUMN \`config\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260309230000_move_org_to_state.ts b/packages/core/src/database/migration/20260309230000_move_org_to_state.ts new file mode 100644 index 000000000..63671a84f --- /dev/null +++ b/packages/core/src/database/migration/20260309230000_move_org_to_state.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260309230000_move_org_to_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`account_state\` ADD \`active_org_id\` text;`) + yield* tx.run(`UPDATE \`account_state\` SET \`active_org_id\` = (SELECT \`selected_org_id\` FROM \`account\` WHERE \`account\`.\`id\` = \`account_state\`.\`active_account_id\`);`) + yield* tx.run(`ALTER TABLE \`account\` DROP COLUMN \`selected_org_id\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260312043431_session_message_cursor.ts b/packages/core/src/database/migration/20260312043431_session_message_cursor.ts new file mode 100644 index 000000000..86e20a66d --- /dev/null +++ b/packages/core/src/database/migration/20260312043431_session_message_cursor.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260312043431_session_message_cursor", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DROP INDEX IF EXISTS \`message_session_idx\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`part_message_idx\`;`) + yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`) + yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260323234822_events.ts b/packages/core/src/database/migration/20260323234822_events.ts new file mode 100644 index 000000000..2b1996fba --- /dev/null +++ b/packages/core/src/database/migration/20260323234822_events.ts @@ -0,0 +1,26 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260323234822_events", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`event_sequence\` ( + \`aggregate_id\` text PRIMARY KEY, + \`seq\` integer NOT NULL + ); + `) + yield* tx.run(` + CREATE TABLE \`event\` ( + \`id\` text PRIMARY KEY, + \`aggregate_id\` text NOT NULL, + \`seq\` integer NOT NULL, + \`type\` text NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260410174513_workspace-name.ts b/packages/core/src/database/migration/20260410174513_workspace-name.ts new file mode 100644 index 000000000..3b37a0bfc --- /dev/null +++ b/packages/core/src/database/migration/20260410174513_workspace-name.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260410174513_workspace-name", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`PRAGMA foreign_keys=OFF;`) + yield* tx.run(` + CREATE TABLE \`__new_workspace\` ( + \`id\` text PRIMARY KEY, + \`type\` text NOT NULL, + \`name\` text DEFAULT '' NOT NULL, + \`branch\` text, + \`directory\` text, + \`extra\` text, + \`project_id\` text NOT NULL, + CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`) + yield* tx.run(`DROP TABLE \`workspace\`;`) + yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`) + yield* tx.run(`PRAGMA foreign_keys=ON;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260413175956_chief_energizer.ts b/packages/core/src/database/migration/20260413175956_chief_energizer.ts new file mode 100644 index 000000000..a03477e09 --- /dev/null +++ b/packages/core/src/database/migration/20260413175956_chief_energizer.ts @@ -0,0 +1,24 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260413175956_chief_energizer", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_entry\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`type\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_session_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE INDEX \`session_entry_session_idx\` ON \`session_entry\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`session_entry_session_type_idx\` ON \`session_entry\` (\`session_id\`,\`type\`);`) + yield* tx.run(`CREATE INDEX \`session_entry_time_created_idx\` ON \`session_entry\` (\`time_created\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260423070820_add_icon_url_override.ts b/packages/core/src/database/migration/20260423070820_add_icon_url_override.ts new file mode 100644 index 000000000..20b1f9163 --- /dev/null +++ b/packages/core/src/database/migration/20260423070820_add_icon_url_override.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260423070820_add_icon_url_override", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + ALTER TABLE \`project\` ADD \`icon_url_override\` text; + UPDATE \`project\` SET \`icon_url_override\` = \`icon_url\` WHERE \`icon_url\` IS NOT NULL; + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260427172553_slow_nightmare.ts b/packages/core/src/database/migration/20260427172553_slow_nightmare.ts new file mode 100644 index 000000000..0b0bd133a --- /dev/null +++ b/packages/core/src/database/migration/20260427172553_slow_nightmare.ts @@ -0,0 +1,28 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260427172553_slow_nightmare", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_message\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`type\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + \`data\` text NOT NULL, + CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_session_idx\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_session_type_idx\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_time_created_idx\`;`) + yield* tx.run(`CREATE INDEX \`session_message_session_idx\` ON \`session_message\` (\`session_id\`);`) + yield* tx.run(`CREATE INDEX \`session_message_session_type_idx\` ON \`session_message\` (\`session_id\`,\`type\`);`) + yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`) + yield* tx.run(`DROP TABLE \`session_entry\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260428004200_add_session_path.ts b/packages/core/src/database/migration/20260428004200_add_session_path.ts new file mode 100644 index 000000000..a60ef377f --- /dev/null +++ b/packages/core/src/database/migration/20260428004200_add_session_path.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260428004200_add_session_path", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`path\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260501142318_next_venus.ts b/packages/core/src/database/migration/20260501142318_next_venus.ts new file mode 100644 index 000000000..6c5b078f8 --- /dev/null +++ b/packages/core/src/database/migration/20260501142318_next_venus.ts @@ -0,0 +1,12 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260501142318_next_venus", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`agent\` text;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`model\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260504145000_add_sync_owner.ts b/packages/core/src/database/migration/20260504145000_add_sync_owner.ts new file mode 100644 index 000000000..33e855491 --- /dev/null +++ b/packages/core/src/database/migration/20260504145000_add_sync_owner.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260504145000_add_sync_owner", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`event_sequence\` ADD \`owner_id\` text;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260507164347_add_workspace_time.ts b/packages/core/src/database/migration/20260507164347_add_workspace_time.ts new file mode 100644 index 000000000..df7e90fc9 --- /dev/null +++ b/packages/core/src/database/migration/20260507164347_add_workspace_time.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260507164347_add_workspace_time", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`workspace\` ADD \`time_used\` integer NOT NULL DEFAULT 0;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260510033149_session_usage.ts b/packages/core/src/database/migration/20260510033149_session_usage.ts new file mode 100644 index 000000000..5dcd1f658 --- /dev/null +++ b/packages/core/src/database/migration/20260510033149_session_usage.ts @@ -0,0 +1,56 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260510033149_session_usage", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`cost\` real DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_input\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_output\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_reasoning\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_cache_read\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_cache_write\` integer DEFAULT 0 NOT NULL;`) + yield* tx.run(` + UPDATE session + SET + cost = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.cost'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_input = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.input'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_output = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.output'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_reasoning = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.reasoning'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_cache_read = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.cache.read'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0), + tokens_cache_write = coalesce(( + SELECT sum(coalesce(json_extract(message.data, '$.tokens.cache.write'), 0)) + FROM message + WHERE message.session_id = session.id + AND json_extract(message.data, '$.role') = 'assistant' + ), 0) + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260511000411_data_migration_state.ts b/packages/core/src/database/migration/20260511000411_data_migration_state.ts new file mode 100644 index 000000000..7ff0b6618 --- /dev/null +++ b/packages/core/src/database/migration/20260511000411_data_migration_state.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260511000411_data_migration_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`data_migration\` ( + \`name\` text PRIMARY KEY, + \`time_completed\` integer NOT NULL + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/opencode/src/storage/schema.sql.ts b/packages/core/src/database/schema.sql.ts similarity index 100% rename from packages/opencode/src/storage/schema.sql.ts rename to packages/core/src/database/schema.sql.ts diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index a4a5dd859..4c57094cd 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,157 +1,2 @@ -export * as EventV2 from "./event" - -import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" -import { Location } from "./location" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" - -export const ID = Schema.String.pipe( - Schema.brand("Event.ID"), - withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })), -) -export type ID = typeof ID.Type - -export type Definition = { - readonly type: Type - readonly version?: number - readonly aggregate?: string - readonly data: DataSchema -} - -export type Data = Schema.Schema.Type - -export type Payload = { - readonly id: ID - readonly type: D["type"] - readonly data: Data - readonly version?: number - readonly location?: Location.Ref - readonly metadata?: Record -} - -export type Sync = (event: Payload) => Effect.Effect - -export const registry = new Map() - -export function define(input: { - readonly type: Type - readonly version?: number - readonly aggregate?: string - readonly schema: Fields -}): Schema.Schema>>> & Definition> { - const Data = Schema.Struct(input.schema) - const Payload = Schema.Struct({ - id: ID, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - type: Schema.Literal(input.type), - version: Schema.optional(Schema.Number), - location: Schema.optional(Location.Ref), - data: Data, - }).annotate({ identifier: input.type }) - - const definition = Object.assign(Payload, { - type: input.type, - ...(input.version === undefined ? {} : { version: input.version }), - ...(input.aggregate === undefined ? {} : { aggregate: input.aggregate }), - data: Data, - }) - registry.set(input.type, definition) - return definition as Schema.Schema>>> & - Definition> -} - -export function definitions() { - return registry.values().toArray() -} - -export interface PublishOptions { - readonly id?: ID - readonly metadata?: Record -} - -export type Unsubscribe = Effect.Effect - -export interface Interface { - readonly publish: ( - definition: D, - data: Data, - options?: PublishOptions, - ) => Effect.Effect> - readonly publishEvent: (event: Payload) => Effect.Effect> - readonly subscribe: (definition: D) => Stream.Stream> - readonly all: () => Stream.Stream - readonly sync: (handler: Sync) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Event") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const all = yield* PubSub.unbounded() - const typed = new Map>() - const syncHandlers = new Array() - - const getOrCreate = (definition: Definition) => - Effect.gen(function* () { - const existing = typed.get(definition.type) - if (existing) return existing - const pubsub = yield* PubSub.unbounded() - typed.set(definition.type, pubsub) - return pubsub - }) - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - yield* PubSub.shutdown(all) - yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true }) - }), - ) - - function publishEvent(event: Payload) { - return Effect.gen(function* () { - for (const sync of syncHandlers) { - yield* sync(event as Payload) - } - const pubsub = typed.get(event.type) - if (pubsub) yield* PubSub.publish(pubsub, event as Payload) - yield* PubSub.publish(all, event as Payload) - return event - }) - } - - function publish(definition: D, data: Data, options?: PublishOptions) { - return Effect.gen(function* () { - const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) - const event = { - id: options?.id ?? ID.create(), - ...(options?.metadata ? { metadata: options.metadata } : {}), - type: definition.type, - ...(definition.version === undefined ? {} : { version: definition.version }), - ...(location ? { location } : {}), - data, - } as Payload - return yield* publishEvent(event) - }) - } - - const subscribe = (definition: D): Stream.Stream> => - Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( - Stream.map((event) => event as Payload), - ) - - const streamAll = (): Stream.Stream => Stream.fromPubSub(all) - const sync = (handler: Sync): Effect.Effect => - Effect.sync(() => { - syncHandlers.push(handler) - return Effect.sync(() => { - const index = syncHandlers.indexOf(handler) - if (index >= 0) syncHandlers.splice(index, 1) - }) - }) - - return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync }) - }), -) - -export const defaultLayer = layer +export * from "./event/index" +export * as EventV2 from "./event/index" diff --git a/packages/core/src/event/index.ts b/packages/core/src/event/index.ts new file mode 100644 index 000000000..12d1c48c8 --- /dev/null +++ b/packages/core/src/event/index.ts @@ -0,0 +1,157 @@ +export * as EventV2 from "." + +import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Location } from "../location" +import { withStatics } from "../schema" +import { Identifier } from "../util/identifier" + +export const ID = Schema.String.pipe( + Schema.brand("Event.ID"), + withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export type Definition = { + readonly type: Type + readonly version?: number + readonly aggregate?: string + readonly data: DataSchema +} + +export type Data = Schema.Schema.Type + +export type Payload = { + readonly id: ID + readonly type: D["type"] + readonly data: Data + readonly version?: number + readonly location?: Location.Ref + readonly metadata?: Record +} + +export type Sync = (event: Payload) => Effect.Effect + +export const registry = new Map() + +export function define(input: { + readonly type: Type + readonly version?: number + readonly aggregate?: string + readonly schema: Fields +}): Schema.Schema>>> & Definition> { + const Data = Schema.Struct(input.schema) + const Payload = Schema.Struct({ + id: ID, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + version: Schema.optional(Schema.Number), + location: Schema.optional(Location.Ref), + data: Data, + }).annotate({ identifier: input.type }) + + const definition = Object.assign(Payload, { + type: input.type, + ...(input.version === undefined ? {} : { version: input.version }), + ...(input.aggregate === undefined ? {} : { aggregate: input.aggregate }), + data: Data, + }) + registry.set(input.type, definition) + return definition as Schema.Schema>>> & + Definition> +} + +export function definitions() { + return registry.values().toArray() +} + +export interface PublishOptions { + readonly id?: ID + readonly metadata?: Record +} + +export type Unsubscribe = Effect.Effect + +export interface Interface { + readonly publish: ( + definition: D, + data: Data, + options?: PublishOptions, + ) => Effect.Effect> + readonly publishEvent: (event: Payload) => Effect.Effect> + readonly subscribe: (definition: D) => Stream.Stream> + readonly all: () => Stream.Stream + readonly sync: (handler: Sync) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Event") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const all = yield* PubSub.unbounded() + const typed = new Map>() + const syncHandlers = new Array() + + const getOrCreate = (definition: Definition) => + Effect.gen(function* () { + const existing = typed.get(definition.type) + if (existing) return existing + const pubsub = yield* PubSub.unbounded() + typed.set(definition.type, pubsub) + return pubsub + }) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* PubSub.shutdown(all) + yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true }) + }), + ) + + function publishEvent(event: Payload) { + return Effect.gen(function* () { + for (const sync of syncHandlers) { + yield* sync(event as Payload) + } + const pubsub = typed.get(event.type) + if (pubsub) yield* PubSub.publish(pubsub, event as Payload) + yield* PubSub.publish(all, event as Payload) + return event + }) + } + + function publish(definition: D, data: Data, options?: PublishOptions) { + return Effect.gen(function* () { + const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) + const event = { + id: options?.id ?? ID.create(), + ...(options?.metadata ? { metadata: options.metadata } : {}), + type: definition.type, + ...(definition.version === undefined ? {} : { version: definition.version }), + ...(location ? { location } : {}), + data, + } as Payload + return yield* publishEvent(event) + }) + } + + const subscribe = (definition: D): Stream.Stream> => + Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( + Stream.map((event) => event as Payload), + ) + + const streamAll = (): Stream.Stream => Stream.fromPubSub(all) + const sync = (handler: Sync): Effect.Effect => + Effect.sync(() => { + syncHandlers.push(handler) + return Effect.sync(() => { + const index = syncHandlers.indexOf(handler) + if (index >= 0) syncHandlers.splice(index, 1) + }) + }) + + return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync }) + }), +) + +export const defaultLayer = layer diff --git a/packages/opencode/src/sync/event.sql.ts b/packages/core/src/event/sql.ts similarity index 86% rename from packages/opencode/src/sync/event.sql.ts rename to packages/core/src/event/sql.ts index 547a80f0f..e4c5f3846 100644 --- a/packages/opencode/src/sync/event.sql.ts +++ b/packages/core/src/event/sql.ts @@ -1,4 +1,5 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" +import type { EventV2 } from "." export const EventSequenceTable = sqliteTable("event_sequence", { aggregate_id: text().notNull().primaryKey(), @@ -7,7 +8,7 @@ export const EventSequenceTable = sqliteTable("event_sequence", { }) export const EventTable = sqliteTable("event", { - id: text().primaryKey(), + id: text().$type().primaryKey(), aggregate_id: text() .notNull() .references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }), diff --git a/packages/core/src/id/id.ts b/packages/core/src/id/id.ts new file mode 100644 index 000000000..847a5c032 --- /dev/null +++ b/packages/core/src/id/id.ts @@ -0,0 +1,80 @@ +import { randomBytes } from "crypto" + +const prefixes = { + job: "job", + event: "evt", + session: "ses", + message: "msg", + permission: "per", + question: "que", + part: "prt", + pty: "pty", + tool: "tool", + workspace: "wrk", +} as const + +const LENGTH = 26 + +// State for monotonic ID generation +let lastTimestamp = 0 +let counter = 0 + +export function ascending(prefix: keyof typeof prefixes, given?: string) { + return generateID(prefix, "ascending", given) +} + +export function descending(prefix: keyof typeof prefixes, given?: string) { + return generateID(prefix, "descending", given) +} + +function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string { + if (!given) { + return create(prefixes[prefix], direction) + } + + if (!given.startsWith(prefixes[prefix])) { + throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) + } + return given +} + +function randomBase62(length: number): string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let result = "" + const bytes = randomBytes(length) + for (let i = 0; i < length; i++) { + result += chars[bytes[i] % 62] + } + return result +} + +export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { + const currentTimestamp = timestamp ?? Date.now() + + if (currentTimestamp !== lastTimestamp) { + lastTimestamp = currentTimestamp + counter = 0 + } + counter++ + + let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + + now = direction === "descending" ? ~now : now + + const timeBytes = Buffer.alloc(6) + for (let i = 0; i < 6; i++) { + timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) +} + +/** Extract timestamp from an ascending ID. Does not work with descending IDs. */ +export function timestamp(id: string): number { + const prefix = id.split("_")[0] + const hex = id.slice(prefix.length + 1, prefix.length + 13) + const encoded = BigInt("0x" + hex) + return Number(encoded / BigInt(0x1000)) +} + +export * as Identifier from "./id" diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index ec8038f71..f1b19dcf9 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,45 +1,2 @@ -export * as PermissionV2 from "./permission" - -import { Schema } from "effect" -import { Wildcard } from "./util/wildcard" - -export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" }) -export type Action = typeof Action.Type - -export const Rule = Schema.Struct({ - permission: Schema.String, - pattern: Schema.String, - action: Action, -}).annotate({ identifier: "PermissionV2.Rule" }) -export type Rule = typeof Rule.Type - -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) -export type Ruleset = typeof Ruleset.Type - -const EDIT_TOOLS = ["edit", "write", "apply_patch"] - -export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - return ( - rulesets - .flat() - .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? { - action: "ask", - permission, - pattern: "*", - } - ) -} - -export function merge(...rulesets: Ruleset[]): Ruleset { - return rulesets.flat() -} - -export function disabled(tools: string[], ruleset: Ruleset): Set { - return new Set( - tools.filter((tool) => { - const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) - return rule?.pattern === "*" && rule.action === "deny" - }), - ) -} +export * from "./permission/index" +export * as PermissionV2 from "./permission/index" diff --git a/packages/core/src/permission/index.ts b/packages/core/src/permission/index.ts new file mode 100644 index 000000000..e9948e4df --- /dev/null +++ b/packages/core/src/permission/index.ts @@ -0,0 +1,56 @@ +export * as PermissionV2 from "." + +import { Schema } from "effect" +import { Wildcard } from "../util/wildcard" +import { Identifier } from "../id/id" +import { Newtype } from "../schema" + +export class PermissionID extends Newtype()( + "PermissionID", + Schema.String.check(Schema.isStartsWith("per")), +) { + static ascending(id?: string): PermissionID { + return this.make(Identifier.ascending("permission", id)) + } +} + +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" }) +export type Action = typeof Action.Type + +export const Rule = Schema.Struct({ + permission: Schema.String, + pattern: Schema.String, + action: Action, +}).annotate({ identifier: "PermissionV2.Rule" }) +export type Rule = typeof Rule.Type + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) +export type Ruleset = typeof Ruleset.Type + +const EDIT_TOOLS = ["edit", "write", "apply_patch"] + +export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + return ( + rulesets + .flat() + .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? { + action: "ask", + permission, + pattern: "*", + } + ) +} + +export function merge(...rulesets: Ruleset[]): Ruleset { + return rulesets.flat() +} + +export function disabled(tools: string[], ruleset: Ruleset): Set { + return new Set( + tools.filter((tool) => { + const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool + const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) + return rule?.pattern === "*" && rule.action === "deny" + }), + ) +} diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index ab2d4cbf7..f35a02848 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -17,9 +17,9 @@ type HookSpec = { } "account.switched": { input: { - serviceID: import("./account").AccountV2.ServiceID - from?: import("./account").AccountV2.ID - to?: import("./account").AccountV2.ID + serviceID: import("./auth").Auth.ServiceID + from?: import("./auth").Auth.ID + to?: import("./auth").Auth.ID } output: {} } diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts index 10ccf047e..26e5f11d1 100644 --- a/packages/core/src/plugin/account.ts +++ b/packages/core/src/plugin/account.ts @@ -1,5 +1,5 @@ import { Effect, Scope, Stream } from "effect" -import { AccountV2 } from "../account" +import { Auth } from "../auth" import { EventV2 } from "../event" import { PluginV2 } from "../plugin" @@ -8,11 +8,11 @@ import { PluginV2 } from "../plugin" export const AccountPlugin = PluginV2.define({ id: PluginV2.ID.make("account"), effect: Effect.gen(function* () { - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const events = yield* EventV2.Service const scope = yield* Scope.Scope - yield* events.subscribe(AccountV2.Event.Switched).pipe( + yield* events.subscribe(Auth.Event.Switched).pipe( Stream.runForEach((event) => PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid), ), @@ -22,7 +22,7 @@ export const AccountPlugin = PluginV2.define({ return { "catalog.transform": Effect.fn(function* (evt) { for (const item of evt.data) { - const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie) + const account = yield* accounts.active(Auth.ServiceID.make(item.provider.id)).pipe(Effect.orDie) if (!account) continue evt.provider.update(item.provider.id, (provider) => { provider.enabled = { diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 5624369e0..b29801a98 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -1,7 +1,7 @@ export * as PluginBoot from "./boot" import { Context, Deferred, Effect, Layer } from "effect" -import { AccountV2 } from "../account" +import { Auth } from "../auth" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { EventV2 } from "../event" @@ -15,7 +15,7 @@ import { ProviderPlugins } from "./provider" type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< - Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service + Catalog.Service | AgentV2.Service | Auth.Service | Npm.Service | EventV2.Service | PluginV2.Service > } @@ -31,7 +31,7 @@ export const layer = Layer.effect( const agent = yield* AgentV2.Service const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const npm = yield* Npm.Service const events = yield* EventV2.Service const done = yield* Deferred.make() @@ -42,7 +42,7 @@ export const layer = Layer.effect( effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), Effect.provideService(AgentV2.Service, agent), - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), @@ -76,6 +76,6 @@ export const defaultLayer = layer.pipe( Layer.provide(Catalog.defaultLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer), - Layer.provide(AccountV2.defaultLayer), + Layer.provide(Auth.defaultLayer), Layer.provide(Npm.defaultLayer), ) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index a9d54f9ea..e0b47338d 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -1,130 +1,2 @@ -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 - readonly locations: (projectID: ID) => Promise - // opencode -> ["~/dev/projects/anomalyco/opencode", "~/.gitworktrees/anomalyci/opencode"] - // global -> ["~/.config/nvim", "/etc/nixos"] - - readonly resolve: (input: AbsolutePath) => Promise - // ~/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()("@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, - }) - }), -) +export * from "./project/index" +export * as Project from "./project/index" diff --git a/packages/core/src/project/index.ts b/packages/core/src/project/index.ts new file mode 100644 index 000000000..6a4d5a69d --- /dev/null +++ b/packages/core/src/project/index.ts @@ -0,0 +1,130 @@ +export * as Project from "." + +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("Project.ID"), + withStatics((schema) => ({ + global: schema.make("global"), + })), +) +export type ID = typeof ID.Type + +export interface Interface { + readonly create: (input: AbsolutePath) => Promise + readonly locations: (projectID: ID) => Promise + // opencode -> ["~/dev/projects/anomalyco/opencode", "~/.gitworktrees/anomalyci/opencode"] + // global -> ["~/.config/nvim", "/etc/nixos"] + + readonly resolve: (input: AbsolutePath) => Promise + // ~/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()("@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, + }) + }), +) diff --git a/packages/opencode/src/project/project.sql.ts b/packages/core/src/project/sql.ts similarity index 75% rename from packages/opencode/src/project/project.sql.ts rename to packages/core/src/project/sql.ts index 2d486114a..50b93c1c5 100644 --- a/packages/opencode/src/project/project.sql.ts +++ b/packages/core/src/project/sql.ts @@ -1,9 +1,9 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" -import { Timestamps } from "../storage/schema.sql" -import type { ProjectID } from "./schema" +import { Timestamps } from "../database/schema.sql" +import { Project } from "." export const ProjectTable = sqliteTable("project", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), worktree: text().notNull(), vcs: text(), name: text(), diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 7ba2172ad..054d3735e 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,120 +1,2 @@ -export * as ProviderV2 from "./provider" - -import { withStatics } from "./schema" -import { Schema } from "effect" - -export const ID = Schema.String.pipe( - Schema.brand("ProviderV2.ID"), - withStatics((schema) => ({ - // Well-known providers - opencode: schema.make("opencode"), - anthropic: schema.make("anthropic"), - openai: schema.make("openai"), - google: schema.make("google"), - googleVertex: schema.make("google-vertex"), - githubCopilot: schema.make("github-copilot"), - amazonBedrock: schema.make("amazon-bedrock"), - azure: schema.make("azure"), - openrouter: schema.make("openrouter"), - mistral: schema.make("mistral"), - gitlab: schema.make("gitlab"), - })), -) -export type ID = typeof ID.Type - -const OpenAIResponses = Schema.Struct({ - type: Schema.Literal("openai/responses"), - url: Schema.String, - websocket: Schema.optional(Schema.Boolean), -}) - -const OpenAICompletions = Schema.Struct({ - type: Schema.Literal("openai/completions"), - url: Schema.String, - reasoning: Schema.Union([ - Schema.Struct({ - type: Schema.Literal("reasoning_content"), - }), - Schema.Struct({ - type: Schema.Literal("reasoning_details"), - }), - ]).pipe(Schema.optional), -}) -export type OpenAICompletions = typeof OpenAICompletions.Type - -const AISDK = Schema.Struct({ - type: Schema.Literal("aisdk"), - package: Schema.String, - url: Schema.String.pipe(Schema.optional), -}) - -const AnthropicMessages = Schema.Struct({ - type: Schema.Literal("anthropic/messages"), - url: Schema.String, -}) - -const UnknownEndpoint = Schema.Struct({ - type: Schema.Literal("unknown"), -}) - -export const Endpoint = Schema.Union([ - UnknownEndpoint, - OpenAIResponses, - OpenAICompletions, - AnthropicMessages, - AISDK, -]).pipe(Schema.toTaggedUnion("type")) -export type Endpoint = typeof Endpoint.Type - -export const Options = Schema.Struct({ - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.Record(Schema.String, Schema.Any), - aisdk: Schema.Struct({ - provider: Schema.Record(Schema.String, Schema.Any), - request: Schema.Record(Schema.String, Schema.Any), - }), -}) -export type Options = typeof Options.Type - -export class Info extends Schema.Class("ProviderV2.Info")({ - id: ID, - name: Schema.String, - enabled: Schema.Union([ - Schema.Literal(false), - Schema.Struct({ - via: Schema.Literal("env"), - name: Schema.String, - }), - Schema.Struct({ - via: Schema.Literal("account"), - service: Schema.String, - }), - Schema.Struct({ - via: Schema.Literal("custom"), - data: Schema.Record(Schema.String, Schema.Any), - }), - ]), - env: Schema.String.pipe(Schema.Array), - endpoint: Endpoint, - options: Options, -}) { - static empty(providerID: ID) { - return new Info({ - id: providerID, - name: providerID, - enabled: false, - env: [], - endpoint: { - type: "unknown", - }, - options: { - headers: {}, - body: {}, - aisdk: { - provider: {}, - request: {}, - }, - }, - }) - } -} +export * from "./provider/index" +export * as ProviderV2 from "./provider/index" diff --git a/packages/core/src/provider/index.ts b/packages/core/src/provider/index.ts new file mode 100644 index 000000000..a30cc358b --- /dev/null +++ b/packages/core/src/provider/index.ts @@ -0,0 +1,123 @@ +export * as ProviderV2 from "." + +import { withStatics } from "../schema" +import { Schema } from "effect" + +export const ID = Schema.String.pipe( + Schema.brand("ProviderV2.ID"), + withStatics((schema) => ({ + // Well-known providers + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ID = typeof ID.Type + +export const ModelID = Schema.String.pipe(Schema.brand("ModelID")) +export type ModelID = typeof ModelID.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AISDK = Schema.Struct({ + type: Schema.Literal("aisdk"), + package: Schema.String, + url: Schema.String.pipe(Schema.optional), +}) + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +const UnknownEndpoint = Schema.Struct({ + type: Schema.Literal("unknown"), +}) + +export const Endpoint = Schema.Union([ + UnknownEndpoint, + OpenAIResponses, + OpenAICompletions, + AnthropicMessages, + AISDK, +]).pipe(Schema.toTaggedUnion("type")) +export type Endpoint = typeof Endpoint.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), + aisdk: Schema.Struct({ + provider: Schema.Record(Schema.String, Schema.Any), + request: Schema.Record(Schema.String, Schema.Any), + }), +}) +export type Options = typeof Options.Type + +export class Info extends Schema.Class("ProviderV2.Info")({ + id: ID, + name: Schema.String, + enabled: Schema.Union([ + Schema.Literal(false), + Schema.Struct({ + via: Schema.Literal("env"), + name: Schema.String, + }), + Schema.Struct({ + via: Schema.Literal("account"), + service: Schema.String, + }), + Schema.Struct({ + via: Schema.Literal("custom"), + data: Schema.Record(Schema.String, Schema.Any), + }), + ]), + env: Schema.String.pipe(Schema.Array), + endpoint: Endpoint, + options: Options, +}) { + static empty(providerID: ID) { + return new Info({ + id: providerID, + name: providerID, + enabled: false, + env: [], + endpoint: { + type: "unknown", + }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + }) + } +} diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 2d5f43105..58eecf287 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -4,7 +4,7 @@ import { ModelV2 } from "../model" import { NonNegativeInt } from "../schema" import { ToolOutput } from "../tool-output" import { V2Schema } from "../v2-schema" -import { Session } from "./index" +import { SessionV2 } from "./index" import { FileAttachment, Prompt } from "./prompt" export { FileAttachment } @@ -20,7 +20,7 @@ export type Source = typeof Source.Type const Base = { timestamp: V2Schema.DateTimeUtcFromMillis, - sessionID: Session.ID, + sessionID: SessionV2.ID, } const options = { diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index 362742355..965174a90 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -1,21 +1,29 @@ -export * as Session from "." +export * as SessionV2 from "." -import { Effect, Schema } from "effect" +import { DateTime, Effect, Layer, Schema, Context } from "effect" +import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "drizzle-orm" 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 { WorkspaceV2 } from "../workspace" +import { ModelV2 } from "../model" import { Location } from "../location" -import type { SessionMessage } from "./message" +import { SessionMessage } from "./message" import type { Prompt } from "./prompt" -import type { EventV2 } from "../event" +import { EventV2 } from "../event" +import { optionalOmitUndefined } from "../schema" +import { V2Schema } from "../v2-schema" +import { ProviderV2 } from "../provider" +import { Database } from "../database/database" +import { SessionMessageTable, SessionTable } from "./sql" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", }) export type Delivery = Schema.Schema.Type +export const DefaultDelivery = "immediate" satisfies Delivery + export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( Schema.brand("SessionID"), withStatics((schema) => ({ @@ -24,13 +32,39 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( ) export type ID = typeof ID.Type -export const Info = Schema.Struct({ +export const LegacyInfo = Schema.Struct({ id: ID, location: Location.Ref, subpath: RelativePath, // derived from location project: Project.ID, // derived from location }) -export type Info = typeof Info.Type +export type LegacyInfo = typeof LegacyInfo.Type + +export class Info extends Schema.Class("Session.Info")({ + id: ID, + parentID: optionalOmitUndefined(ID), + projectID: Project.ID, + workspaceID: optionalOmitUndefined(WorkspaceV2.ID), + path: optionalOmitUndefined(Schema.String), + agent: optionalOmitUndefined(Schema.String), + model: ModelV2.Ref.pipe(optionalOmitUndefined), + cost: Schema.Finite, + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + updated: V2Schema.DateTimeUtcFromMillis, + archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), + }), + title: Schema.String, +}) {} // get project -> project.locations // @@ -41,29 +75,32 @@ export type Info = typeof Info.Type // - by subpath // - by workspace (home is special) -type Cursor = {} +type Cursor = { + id: ID + time: number + direction: "previous" | "next" +} type ListInput = { - workspaceID?: Workspace.ID + workspaceID?: WorkspaceV2.ID + projectID?: Project.ID + path?: string + roots?: boolean + start?: number search?: string cursor?: Cursor limit?: number order?: "asc" | "desc" -} & ( - | { - project: Project.ID - subpath?: RelativePath - } - | { - directory?: AbsolutePath - } -) + directory?: string +} type CreateInput = { id?: ID agent?: string model?: ModelV2.Ref - location: Location.Ref + location?: Location.Ref + parentID?: ID + workspaceID?: WorkspaceV2.ID } type MoveInput = { @@ -80,7 +117,19 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses sessionID: ID, }) {} -export type Error = NotFoundError +export class OperationUnavailableError extends Schema.TaggedErrorClass()( + "Session.OperationUnavailableError", + { + operation: Schema.Literals(["prompt", "compact", "wait"]), + }, +) {} + +export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { + sessionID: ID, + messageID: SessionMessage.ID, +}) {} + +export type Error = NotFoundError | OperationUnavailableError | MessageDecodeError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect @@ -96,8 +145,16 @@ export interface Interface { time: number direction: "previous" | "next" } - }) => Effect.Effect - readonly context: (sessionID: ID) => Effect.Effect + }) => Effect.Effect + readonly context: (sessionID: ID) => Effect.Effect + readonly subagent: (input: { + id?: EventV2.ID + parentID: ID + prompt: Prompt + agent: string + model?: ModelV2.Ref + resume?: boolean + }) => Effect.Effect readonly switchAgent: (input: { sessionID: ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: ID; model: ModelV2.Ref }) => Effect.Effect readonly prompt: (input: { @@ -106,7 +163,7 @@ export interface Interface { prompt: Prompt delivery?: Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID sessionID: ID @@ -121,7 +178,191 @@ export interface Interface { delivery?: Delivery resume?: boolean }) => Effect.Effect - readonly compact: (input: CompactInput) => Effect.Effect - readonly wait: (id: ID) => Effect.Effect + readonly compact: (input: CompactInput | ID) => Effect.Effect + readonly wait: (id: ID) => Effect.Effect readonly resume: (sessionID: ID) => Effect.Effect } + +export class Service extends Context.Service()("@opencode/v2/Session") {} + +function fromRow(row: typeof SessionTable.$inferSelect): Info { + return new Info({ + id: ID.make(row.id), + projectID: Project.ID.make(row.project_id), + workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined, + title: row.title, + parentID: row.parent_id ? ID.make(row.parent_id) : undefined, + path: row.path ?? "", + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelV2.ID.make(row.model.id), + providerID: ProviderV2.ID.make(row.model.providerID), + variant: ModelV2.VariantID.make(row.model.variant ?? "default"), + } + : undefined, + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: row.tokens_cache_write, + }, + }, + time: { + created: DateTime.makeUnsafe(row.time_created), + updated: DateTime.makeUnsafe(row.time_updated), + archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, + }, + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const db = yield* Database.Service + const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + + const decode = (row: typeof SessionMessageTable.$inferSelect) => + decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( + Effect.mapError( + () => + new MessageDecodeError({ + sessionID: ID.make(row.session_id), + messageID: SessionMessage.ID.make(row.id), + }), + ), + ) + + const result = Service.of({ + create: Effect.fn("V2Session.create")(function* () { + return {} as Info + }), + get: Effect.fn("V2Session.get")(function* (sessionID) { + const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie) + if (!row) return yield* new NotFoundError({ sessionID }) + return fromRow(row) + }), + list: Effect.fn("V2Session.list")(function* (input = {}) { + const direction = input.cursor?.direction ?? "next" + const requestedOrder = input.order ?? "desc" + const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder + const sortColumn = SessionTable.time_updated + const conditions: SQL[] = [] + if (input.directory) conditions.push(eq(SessionTable.directory, input.directory)) + if (input.path) + conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) + if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + if (input.projectID) conditions.push(eq(SessionTable.project_id, input.projectID)) + if (input.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input.start) conditions.push(gte(sortColumn, input.start)) + if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.cursor) { + conditions.push( + order === "asc" + ? or(gt(sortColumn, input.cursor.time), and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)))! + : or(lt(sortColumn, input.cursor.time), and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)))!, + ) + } + const query = db + .select() + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + order === "asc" ? asc(sortColumn) : desc(sortColumn), + order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), + ) + const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(Effect.orDie) + return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) + }), + messages: Effect.fn("V2Session.messages")(function* (input) { + yield* result.get(input.sessionID) + const direction = input.cursor?.direction ?? "next" + const requestedOrder = input.order ?? "desc" + const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder + const boundary = input.cursor + ? order === "asc" + ? or( + gt(SessionMessageTable.time_created, input.cursor.time), + and(eq(SessionMessageTable.time_created, input.cursor.time), gt(SessionMessageTable.id, input.cursor.id)), + ) + : or( + lt(SessionMessageTable.time_created, input.cursor.time), + and(eq(SessionMessageTable.time_created, input.cursor.time), lt(SessionMessageTable.id, input.cursor.id)), + ) + : undefined + const where = boundary + ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) + : eq(SessionMessageTable.session_id, input.sessionID) + const query = db + .select() + .from(SessionMessageTable) + .where(where) + .orderBy( + order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created), + order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id), + ) + const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(Effect.orDie) + return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, (row) => decode(row)) + }), + context: Effect.fn("V2Session.context")(function* (sessionID) { + yield* result.get(sessionID) + const compaction = yield* db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id)) + .limit(1) + .get() + .pipe(Effect.orDie) + const rows = yield* db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, sessionID), + compaction + ? or( + gt(SessionMessageTable.time_created, compaction.time_created), + and(eq(SessionMessageTable.time_created, compaction.time_created), gte(SessionMessageTable.id, compaction.id)), + ) + : undefined, + ), + ) + .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id)) + .all() + .pipe(Effect.orDie) + return yield* Effect.forEach(rows, (row) => decode(row)) + }), + prompt: Effect.fn("V2Session.prompt")(function* (input) { + yield* result.get(input.sessionID) + return yield* new OperationUnavailableError({ operation: "prompt" }) + }), + shell: Effect.fn("V2Session.shell")(function* () {}), + skill: Effect.fn("V2Session.skill")(function* () {}), + switchAgent: Effect.fn("V2Session.switchAgent")(function* () {}), + switchModel: Effect.fn("V2Session.switchModel")(function* () {}), + subagent: Effect.fn("V2Session.subagent")(function* (input) { + yield* result.get(input.parentID) + return yield* new OperationUnavailableError({ operation: "prompt" }) + }), + compact: Effect.fn("V2Session.compact")(function* (input) { + const sessionID = typeof input === "string" ? input : input.sessionID + yield* result.get(sessionID) + return yield* new OperationUnavailableError({ operation: "compact" }) + }), + wait: Effect.fn("V2Session.wait")(function* (sessionID) { + yield* result.get(sessionID) + return yield* new OperationUnavailableError({ operation: "wait" }) + }), + resume: Effect.fn("V2Session.resume")(function* () {}), + move: Effect.fn("V2Session.move")(function* () {}), + }) + + return result + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.orDie) diff --git a/packages/core/src/session/legacy.ts b/packages/core/src/session/legacy.ts new file mode 100644 index 000000000..3210bc7c3 --- /dev/null +++ b/packages/core/src/session/legacy.ts @@ -0,0 +1,17 @@ +export * as SessionLegacy from "./legacy" + +import { Schema } from "effect" +import { withStatics } from "../schema" +import { Identifier } from "../util/identifier" + +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( + Schema.brand("MessageID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })), +) +export type MessageID = typeof MessageID.Type + +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( + Schema.brand("PartID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + Identifier.ascending()) })), +) +export type PartID = typeof PartID.Type diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index 305202b0a..9de73a17b 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -1,3 +1,5 @@ +export * as SessionMessage from "./message" + import { Schema } from "effect" import { EventV2 } from "../event" import { ModelV2 } from "../model" @@ -169,5 +171,3 @@ export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthet export type Message = Schema.Schema.Type export type Type = Message["type"] - -export * as SessionMessage from "./message" diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 310ffde0f..795cc694c 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -1,13 +1,26 @@ -import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core" -import { Session } from "." +import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core" +import { ProjectTable } from "../project/sql" +import type { SessionMessage } from "./message" +import type { Snapshot } from "../snapshot" +import { PermissionV2 } from "../permission" +import { Project } from "../project" +import type { ID } from "." +import type { MessageID, PartID } from "./legacy" +import { WorkspaceV2 } from "../workspace" +import { Timestamps } from "../database/schema.sql" + +type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> export const SessionTable = sqliteTable( "session", { - id: text().$type().primaryKey(), - project_id: text().notNull(), - workspace_id: text(), - parent_id: text().$type(), + id: text().$type().primaryKey(), + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + workspace_id: text().$type(), + parent_id: text().$type(), slug: text().notNull(), directory: text().notNull(), path: text(), @@ -17,27 +30,22 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - summary_diffs: text({ mode: "json" }), + summary_diffs: text({ mode: "json" }).$type(), 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" }), + revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), + permission: text({ mode: "json" }).$type(), 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()), + ...Timestamps, time_compacting: integer(), time_archived: integer(), }, @@ -47,3 +55,81 @@ export const SessionTable = sqliteTable( index("session_parent_idx").on(table.parent_id), ], ) + +export const MessageTable = sqliteTable( + "message", + { + id: text().$type().primaryKey(), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + ...Timestamps, + data: text({ mode: "json" }).notNull(), + }, + (table) => [index("message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id)], +) + +export const PartTable = sqliteTable( + "part", + { + id: text().$type().primaryKey(), + message_id: text() + .$type() + .notNull() + .references(() => MessageTable.id, { onDelete: "cascade" }), + session_id: text().$type().notNull(), + ...Timestamps, + data: text({ mode: "json" }).notNull(), + }, + (table) => [ + index("part_message_id_id_idx").on(table.message_id, table.id), + index("part_session_idx").on(table.session_id), + ], +) + +export const TodoTable = sqliteTable( + "todo", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + content: text().notNull(), + status: text().notNull(), + priority: text().notNull(), + position: integer().notNull(), + ...Timestamps, + }, + (table) => [ + primaryKey({ columns: [table.session_id, table.position] }), + index("todo_session_idx").on(table.session_id), + ], +) + +export const SessionMessageTable = sqliteTable( + "session_message", + { + id: text().$type().primaryKey(), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + type: text().$type().notNull(), + ...Timestamps, + data: text({ mode: "json" }).notNull().$type(), + }, + (table) => [ + index("session_message_session_idx").on(table.session_id), + index("session_message_session_type_idx").on(table.session_id, table.type), + index("session_message_time_created_idx").on(table.time_created), + ], +) + +export const PermissionTable = sqliteTable("permission", { + project_id: text() + .primaryKey() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + ...Timestamps, + data: text({ mode: "json" }).notNull().$type(), +}) diff --git a/packages/opencode/src/share/share.sql.ts b/packages/core/src/share/sql.ts similarity index 75% rename from packages/opencode/src/share/share.sql.ts rename to packages/core/src/share/sql.ts index f337e106a..a7a08d0c0 100644 --- a/packages/opencode/src/share/share.sql.ts +++ b/packages/core/src/share/sql.ts @@ -1,6 +1,6 @@ import { sqliteTable, text } from "drizzle-orm/sqlite-core" -import { SessionTable } from "../session/session.sql" -import { Timestamps } from "../storage/schema.sql" +import { SessionTable } from "../session/sql" +import { Timestamps } from "../database/schema.sql" export const SessionShareTable = sqliteTable("session_share", { session_id: text() diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts new file mode 100644 index 000000000..b39c0f7f0 --- /dev/null +++ b/packages/core/src/snapshot.ts @@ -0,0 +1,9 @@ +export namespace Snapshot { + export type FileDiff = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" + } +} diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index a890780a1..da0cdf973 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -1,11 +1,11 @@ -export * as Workspace from "./workspace" +export * as WorkspaceV2 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"), + Schema.brand("WorkspaceV2.ID"), withStatics((schema) => ({ create: () => schema.make("wrk_" + Identifier.ascending()) })), ) export type ID = typeof ID.Type diff --git a/packages/core/test/account.test.ts b/packages/core/test/account.test.ts index cf60740b1..7b287cd04 100644 --- a/packages/core/test/account.test.ts +++ b/packages/core/test/account.test.ts @@ -2,7 +2,7 @@ import path from "path" import { describe, expect } from "bun:test" import { produce } from "immer" import { Effect, Fiber, Layer, Option, Stream } from "effect" -import { AccountV2 } from "@opencode-ai/core/account" +import { Auth } from "@opencode-ai/core/auth" import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -52,7 +52,7 @@ function context( } function testLayer(dir: string) { - return AccountV2.layer.pipe( + return Auth.layer.pipe( Layer.provide(AppFileSystem.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), Layer.provide( @@ -70,7 +70,7 @@ function testLayer(dir: string) { ) } -describe("AccountV2", () => { +describe("Auth", () => { it.live("emits account lifecycle events", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -78,23 +78,23 @@ describe("AccountV2", () => { ).pipe( Effect.flatMap((tmp) => Effect.gen(function* () { - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const eventSvc = yield* EventV2.Service const addedFiber = yield* eventSvc - .subscribe(AccountV2.Event.Added) + .subscribe(Auth.Event.Added) .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped) const switchedFiber = yield* eventSvc - .subscribe(AccountV2.Event.Switched) + .subscribe(Auth.Event.Switched) .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) const removedFiber = yield* eventSvc - .subscribe(AccountV2.Event.Removed) + .subscribe(Auth.Event.Removed) .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow const first = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "raw-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "raw-key" }), }) expect(first).toBeDefined() if (!first) return @@ -109,8 +109,8 @@ describe("AccountV2", () => { if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key") const second = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), }) expect(second).toBeDefined() if (!second) return @@ -121,9 +121,9 @@ describe("AccountV2", () => { const removed = Array.from(yield* Fiber.join(removedFiber)) expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id]) expect(switched.map((event) => event.data)).toEqual([ - { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id }, - { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id }, - { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: first.id }, + { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id }, + { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id }, + { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: first.id }, ]) expect(removed[0]?.data.account.id).toBe(second.id) }).pipe(Effect.provide(testLayer(tmp.path))), @@ -138,25 +138,25 @@ describe("AccountV2", () => { ).pipe( Effect.flatMap((tmp) => Effect.gen(function* () { - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const eventSvc = yield* EventV2.Service const switchedFiber = yield* eventSvc - .subscribe(AccountV2.Event.Switched) + .subscribe(Auth.Event.Switched) .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow const first = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }), }) const second = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), }) const third = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "third-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "third-key" }), }) expect(first).toBeDefined() @@ -164,11 +164,11 @@ describe("AccountV2", () => { expect(third).toBeDefined() if (!first || !second || !third) return - expect((yield* accounts.active(AccountV2.ServiceID.make("provider")))?.id).toBe(third.id) + expect((yield* accounts.active(Auth.ServiceID.make("provider")))?.id).toBe(third.id) expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([ - { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id }, - { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id }, - { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: third.id }, + { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id }, + { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id }, + { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: third.id }, ]) }).pipe(Effect.provide(testLayer(tmp.path))), ), @@ -182,7 +182,7 @@ describe("AccountV2", () => { ).pipe( Effect.flatMap((tmp) => Effect.gen(function* () { - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const plugin = yield* PluginV2.Service const records = [ { @@ -212,7 +212,7 @@ describe("AccountV2", () => { yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, eventSvc), Effect.provideService(PluginV2.Service, plugin), @@ -221,8 +221,8 @@ describe("AccountV2", () => { yield* Effect.yieldNow const first = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }), }) expect(first).toBeDefined() if (!first) return @@ -230,15 +230,15 @@ describe("AccountV2", () => { expect(updates).toEqual([ { id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + enabled: { via: "account", service: Auth.ServiceID.make("provider") }, apiKey: "first-key", }, ]) updates.length = 0 const second = yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("provider"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }), + serviceID: Auth.ServiceID.make("provider"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }), }) expect(second).toBeDefined() if (!second) return @@ -246,7 +246,7 @@ describe("AccountV2", () => { expect(updates).toEqual([ { id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + enabled: { via: "account", service: Auth.ServiceID.make("provider") }, apiKey: "second-key", }, ]) @@ -257,7 +257,7 @@ describe("AccountV2", () => { expect(updates).toEqual([ { id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + enabled: { via: "account", service: Auth.ServiceID.make("provider") }, apiKey: "first-key", }, ]) @@ -268,7 +268,7 @@ describe("AccountV2", () => { expect(updates).toEqual([ { id: ProviderV2.ID.make("provider"), - enabled: { via: "account", service: AccountV2.ServiceID.make("provider") }, + enabled: { via: "account", service: Auth.ServiceID.make("provider") }, apiKey: "second-key", }, ]) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 97f816d00..6ad47a0de 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -6,9 +6,10 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "./lib/effect" -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: AbsolutePath.make("test") })) const it = testEffect( Catalog.layer.pipe( Layer.provideMerge(EventV2.defaultLayer), diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index d5a3de662..4df5982e4 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -1,158 +1,94 @@ import { describe, expect, test } from "bun:test" +import { SqliteClient } from "@effect/sql-sqlite-bun" +import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { Effect } from "effect" +import { sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" +import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage" +import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" -const rand = (seed: number) => () => { - seed = (seed * 1664525 + 1013904223) >>> 0 - return seed / 0x100000000 -} +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped)) + +const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { - test("diff creates missing tables before indexes", () => { - const table = makeTable( - "session", - [makeColumn("id", { primaryKey: true })], - [makeIndex("session_id_idx", "session", ["id"])], - ) + test("applies tracked migrations to an empty database", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) - expect(DatabaseMigration.diff(emptySchema(), schema(table))).toEqual([ - { type: "create_table", table }, - { type: "create_index", index: table.indexes.session_id_idx }, - ]) + expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ + name: "session", + }) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 20 }) + }), + ) }) - 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), - ), + test("runs session usage backfill in order with schema changes", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`) + yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`) + yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`) + yield* db.run( + sql`INSERT INTO message (id, session_id, data) VALUES ('message_1', 'session_1', '{"role":"assistant","cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`, ) - const operations = DatabaseMigration.diff(schema(...actualTables), schema(...desiredTables)) - const expected = desiredTables.flatMap((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) - } + yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration]) + + expect( + yield* db.get( + sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`, + ), + ).toEqual({ + cost: 1.25, + tokens_input: 2, + tokens_output: 3, + tokens_reasoning: 4, + tokens_cache_read: 5, + tokens_cache_write: 6, + }) + }), + ) }) - 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)) + test("imports existing drizzle migration state", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`) + yield* db.run(sql` + INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) + VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) + `) - for (const operation of operations) { - const rendered = DatabaseMigration.toSql(operation) - expect(rendered).not.toContain("undefined") - expect(rendered).toContain('"') - } - } + yield* DatabaseMigration.applyOnly(db, []) + + expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" }) + }), + ) + }) + + test("skips drizzle import when migration table already has state", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`) + yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`) + yield* db.run(sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`) + yield* db.run(sql` + INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) + VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) + `) + + yield* DatabaseMigration.applyOnly(db, []) + + expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }]) + }), + ) }) }) - -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> = {}, -): 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) -} diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index b67b2897a..01e7847d1 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -2,11 +2,12 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "./lib/effect" const locationLayer = Layer.succeed( Location.Service, - Location.Service.of({ directory: "project", workspaceID: "workspace" }), + Location.Service.of({ directory: AbsolutePath.make("project"), workspaceID: "workspace" }), ) const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer))) const itWithoutLocation = testEffect(EventV2.layer) @@ -46,7 +47,7 @@ describe("EventV2", () => { expect(event.type).toBe("test.message") expect(event).not.toHaveProperty("version") expect(event.data).toEqual({ text: "hello" }) - expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" }) + expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" }) }), ) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 8c8995a37..6d98a0bf2 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { AccountV2 } from "@opencode-ai/core/account" +import { Auth } from "@opencode-ai/core/auth" import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" @@ -8,15 +8,16 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" const itWithAccount = testEffect( Catalog.layer.pipe( Layer.provideMerge(PluginV2.defaultLayer), - Layer.provideMerge(AccountV2.defaultLayer), + Layer.provideMerge(Auth.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: AbsolutePath.make("test") }))), Layer.provideMerge(npmLayer), ), ) @@ -73,12 +74,12 @@ describe("AzurePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const catalog = yield* Catalog.Service const events = yield* EventV2.Service yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("azure"), - credential: new AccountV2.ApiKeyCredential({ + serviceID: Auth.ServiceID.make("azure"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "key", metadata: { resourceName: "from-account" }, @@ -87,7 +88,7 @@ describe("AzurePlugin", () => { yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d1db7b27a..d940e9013 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { AccountV2 } from "@opencode-ai/core/account" +import { Auth } from "@opencode-ai/core/auth" import { Catalog } from "@opencode-ai/core/catalog" import { Location } from "@opencode-ai/core/location" import { EventV2 } from "@opencode-ai/core/event" @@ -9,15 +9,16 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "../lib/effect" import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" const itWithAccount = testEffect( Catalog.layer.pipe( Layer.provideMerge(PluginV2.defaultLayer), - Layer.provideMerge(AccountV2.defaultLayer), + Layer.provideMerge(Auth.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: AbsolutePath.make("test") }))), Layer.provideMerge(npmLayer), ), ) @@ -125,12 +126,12 @@ describe("CloudflareWorkersAIPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const catalog = yield* Catalog.Service const events = yield* EventV2.Service yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("cloudflare-workers-ai"), - credential: new AccountV2.ApiKeyCredential({ + serviceID: Auth.ServiceID.make("cloudflare-workers-ai"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "account-key", metadata: { accountId: "account-acct" }, @@ -139,7 +140,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index e785fbbb7..a8a580d29 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,6 +1,6 @@ import { describe, expect, mock } from "bun:test" import { Effect, Layer } from "effect" -import { AccountV2 } from "@opencode-ai/core/account" +import { Auth } from "@opencode-ai/core/auth" import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" @@ -8,6 +8,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { AccountPlugin } from "@opencode-ai/core/plugin/account" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "../lib/effect" import { it, model, npmLayer, withEnv } from "./provider-helper" @@ -29,9 +30,9 @@ void mock.module("gitlab-ai-provider", () => ({ const itWithAccount = testEffect( Catalog.layer.pipe( Layer.provideMerge(PluginV2.defaultLayer), - Layer.provideMerge(AccountV2.defaultLayer), + Layer.provideMerge(Auth.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))), + Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: AbsolutePath.make("test") }))), Layer.provideMerge(npmLayer), ), ) @@ -162,17 +163,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const catalog = yield* Catalog.Service const events = yield* EventV2.Service yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("gitlab"), - credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }), + serviceID: Auth.ServiceID.make("gitlab"), + credential: new Auth.ApiKeyCredential({ type: "api", key: "account-token" }), }) yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), @@ -205,12 +206,12 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - const accounts = yield* AccountV2.Service + const accounts = yield* Auth.Service const catalog = yield* Catalog.Service const events = yield* EventV2.Service yield* accounts.create({ - serviceID: AccountV2.ServiceID.make("gitlab"), - credential: new AccountV2.OAuthCredential({ + serviceID: Auth.ServiceID.make("gitlab"), + credential: new Auth.OAuthCredential({ type: "oauth", refresh: "refresh-token", access: "account-oauth-token", @@ -220,7 +221,7 @@ describe("GitLabPlugin", () => { yield* plugin.add({ ...AccountPlugin, effect: AccountPlugin.effect.pipe( - Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(Auth.Service, accounts), Effect.provideService(Catalog.Service, catalog), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 1b8f1c65a..dc5ae778b 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -8,10 +8,11 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "../lib/effect" export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: AbsolutePath.make("test") })) export const npmLayer = Layer.succeed( Npm.Service, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 3f59a3497..f6495f7a7 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -6,10 +6,11 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" import { it, model, provider, withEnv } from "./provider-helper" const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) +const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: AbsolutePath.make("test") })) describe("OpencodePlugin", () => { it.effect("uses a public key and disables paid models without credentials", () => diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index d367f4408..f07170c58 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -2,12 +2,8 @@ ## Database -- **Schema**: Drizzle schema lives in `src/**/*.sql.ts`. -- **Naming**: tables and columns use snake*case; join columns are `_id`; indexes are `*\_idx`. -- **Migrations**: generated by Drizzle Kit using `drizzle.config.ts` (schema: `./src/**/*.sql.ts`, output: `./migration`). -- **Command**: `bun run db generate --name `. -- **Output**: creates `migration/_/migration.sql` and `snapshot.json`. -- **Tests**: migration tests should read the per-folder layout (no `_journal.json`). +- **Schema**: Drizzle schema lives in `packages/core/src/**/*.sql.ts`. +- **Migrations**: database migrations live in `packages/core` and are applied by core. ## Development server diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 30ae2405c..c852e8725 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -15,8 +15,7 @@ "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "dev": "bun run --conditions=browser ./src/index.ts", - "dev:temporary": "bun run --conditions=browser ./src/temporary.ts", - "db": "bun drizzle-kit" + "dev:temporary": "bun run --conditions=browser ./src/temporary.ts" }, "bin": { "opencode": "./bin/opencode" @@ -62,7 +61,6 @@ "@types/which": "3.0.4", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "drizzle-kit": "catalog:", "drizzle-orm": "catalog:", "prettier": "3.6.2", "typescript": "catalog:", diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts index 0f0d55b46..e6a4171f7 100755 --- a/packages/opencode/script/build-node.ts +++ b/packages/opencode/script/build-node.ts @@ -1,7 +1,6 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" -import fs from "fs" import path from "path" import { fileURLToPath } from "url" @@ -13,36 +12,6 @@ process.chdir(dir) const generated = await import("./generate.ts") -// Load migrations from migration directories -const migrationDirs = ( - await fs.promises.readdir(path.join(dir, "migration"), { - withFileTypes: true, - }) -) - .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name)) - .map((entry) => entry.name) - .sort() - -const migrations = await Promise.all( - migrationDirs.map(async (name) => { - const file = path.join(dir, "migration", name, "migration.sql") - const sql = await Bun.file(file).text() - const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name) - const timestamp = match - ? Date.UTC( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ) - : 0 - return { sql, timestamp, name } - }), -) -console.log(`Loaded ${migrations.length} migrations`) - await Bun.build({ target: "node", entrypoints: ["./src/node.ts"], @@ -51,7 +20,6 @@ await Bun.build({ sourcemap: "linked", external: ["jsonc-parser", "@lydell/node-pty"], define: { - OPENCODE_MIGRATIONS: JSON.stringify(migrations), OPENCODE_MODELS_DEV: generated.modelsData, OPENCODE_CHANNEL: `'${Script.channel}'`, }, diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 33db38d84..c93ae46d1 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -17,36 +17,6 @@ const generated = await import("./generate.ts") import { Script } from "@opencode-ai/script" import pkg from "../package.json" -// Load migrations from migration directories -const migrationDirs = ( - await fs.promises.readdir(path.join(dir, "migration"), { - withFileTypes: true, - }) -) - .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name)) - .map((entry) => entry.name) - .sort() - -const migrations = await Promise.all( - migrationDirs.map(async (name) => { - const file = path.join(dir, "migration", name, "migration.sql") - const sql = await Bun.file(file).text() - const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name) - const timestamp = match - ? Date.UTC( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ) - : 0 - return { sql, timestamp, name } - }), -) -console.log(`Loaded ${migrations.length} migrations`) - const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") @@ -217,7 +187,6 @@ for (const item of targets) { entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])], define: { OPENCODE_VERSION: `'${Script.version}'`, - OPENCODE_MIGRATIONS: JSON.stringify(migrations), OPENCODE_MODELS_DEV: generated.modelsData, OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath, OPENCODE_WORKER_PATH: workerPath, diff --git a/packages/opencode/script/check-migrations.ts b/packages/opencode/script/check-migrations.ts deleted file mode 100644 index f5eaf7932..000000000 --- a/packages/opencode/script/check-migrations.ts +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bun - -import { $ } from "bun" - -// drizzle-kit check compares schema to migrations, exits non-zero if drift -const result = await $`bun drizzle-kit check`.quiet().nothrow() - -if (result.exitCode !== 0) { - console.error("Schema has changes not captured in migrations!") - console.error("Run: bun drizzle-kit generate") - console.error("") - console.error(result.stderr.toString()) - process.exit(1) -} - -console.log("Migrations are up to date") diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index a5291e828..115fa64a3 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -3,7 +3,7 @@ import { serviceUse } from "@/effect/service-use" import { Effect, Layer, Option, Schema, Context } from "effect" import { Database } from "@/storage/db" -import { AccountStateTable, AccountTable } from "./account.sql" +import { AccountStateTable, AccountTable } from "@opencode-ai/core/account/sql" import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema" import { normalizeServerUrl } from "./url" diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 56866a0e0..aab701898 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -3,6 +3,7 @@ import { Effect, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" export const V2Command = effectCmd({ @@ -37,7 +38,7 @@ export const V2Command = effectCmd({ Effect.withSpan("Cli.debug.v2"), Effect.provide( LocationServiceMap.get({ - directory: process.cwd(), + directory: AbsolutePath.make(process.cwd()), }), ), Effect.provide(LocationServiceMap.layer), diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 569aa309a..c5a701419 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -3,7 +3,7 @@ import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" -import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" +import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" import { InstanceRef } from "@/effect/instance-ref" import { ShareNext } from "@/share/share-next" import { EOL } from "os" @@ -196,7 +196,7 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: Ins id, session_id: row.id, time_created: msgInfo.time?.created ?? Date.now(), - data: msgData, + data: msgData as never, }) .onConflictDoNothing() .run(), diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 7ee16c2e2..3a937b4fb 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -3,7 +3,7 @@ import { effectCmd } from "../effect-cmd" import { Session } from "@/session/session" import { NotFoundError } from "@/storage/storage" import { Database } from "@/storage/db" -import { SessionTable } from "../../session/session.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 1954543f4..7af1e59c8 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,14 +1,12 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { withStatics } from "@opencode-ai/core/schema" -const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID")) - -export type WorkspaceID = typeof workspaceIdSchema.Type - -export const WorkspaceID = workspaceIdSchema.pipe( - withStatics((schema: typeof workspaceIdSchema) => ({ +export const WorkspaceID = WorkspaceV2.ID.pipe( + withStatics((schema: typeof WorkspaceV2.ID) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), })), ) +export type WorkspaceID = typeof WorkspaceID.Type diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index d1b4b12f6..9c7ab8f55 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -10,19 +10,19 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" import { SyncEvent } from "@/sync" -import { EventSequenceTable, EventTable } from "@/sync/event.sql" +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" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" -import { WorkspaceTable } from "./workspace.sql" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { getAdapter, registeredAdapters } from "./adapters" import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" import { SessionPrompt } from "@/session/prompt" -import { SessionTable } from "@/session/session.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { errorData } from "@/util/error" diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index b6956032a..ddd0f1337 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -1,9 +1,9 @@ import { Context, Effect, Layer } from "effect" import { Database } from "./storage/db" -import { DataMigrationTable } from "./data-migration.sql" +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 "./session/session.sql" +import { MessageTable, SessionTable } from "@opencode-ai/core/session/sql" import type { SessionID } from "./session/schema" export type Migration = { diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1814c5ab2..44b6b0975 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -4,7 +4,7 @@ import { ConfigPermission } from "@/config/permission" import { InstanceState } from "@/effect/instance-state" import { ProjectID } from "@/project/schema" import { MessageID, SessionID } from "@/session/schema" -import { PermissionTable } from "@/session/session.sql" +import { PermissionTable } from "@opencode-ai/core/session/sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 5107fde3e..d922ba599 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,8 +1,8 @@ import { and } from "drizzle-orm" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { ProjectTable } from "./project.sql" -import { SessionTable } from "../session/session.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { BusEvent } from "@/bus/bus-event" diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index e511a75ff..df4cbe48d 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,13 +1,6 @@ import { Schema } from "effect" -import { withStatics } from "@opencode-ai/core/schema" +import { Project } from "@opencode-ai/core/project" -const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) - -export type ProjectID = typeof projectIdSchema.Type - -export const ProjectID = projectIdSchema.pipe( - withStatics((schema: typeof projectIdSchema) => ({ - global: schema.make("global"), - })), -) +export const ProjectID = Project.ID +export type ProjectID = typeof ProjectID.Type diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index c5fb2420a..7aa7551db 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -1,7 +1,7 @@ import sessionProjectors from "../session/projectors" import { SyncEvent } from "@/sync" import { Session } from "@/session/session" -import { SessionTable } from "@/session/session.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts index f2a9a3355..d30d360e2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts @@ -2,6 +2,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PluginBoot } from "@opencode-ai/core/plugin/boot" +import { AbsolutePath } from "@opencode-ai/core/schema" import { Effect, Layer, Schema } from "effect" import { HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" @@ -40,7 +41,7 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service< function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref { const query = new URL(request.url, "http://localhost").searchParams return { - directory: query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(), + directory: AbsolutePath.make(query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd()), workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"], } } diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 0389fdf25..2ad4562d1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,7 +1,6 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/core/session/prompt" -import { SessionV2 } from "@/v2/session" +import { SessionV2 } from "@opencode-ai/core/session/index" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { @@ -58,24 +57,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session") }), ), ) - .add( - HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { - params: { sessionID: SessionID }, - query: WorkspaceRoutingQuery, - payload: Schema.Struct({ - prompt: Prompt, - delivery: SessionV2.Delivery.pipe(Schema.optional), - }), - success: SessionMessage.Message, - error: [SessionNotFoundError, ServiceUnavailableError], - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.session.prompt", - summary: "Send v2 message", - description: "Create a v2 session message and queue it for the agent loop.", - }), - ), - ) .add( HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionID }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index ffe8d0baa..e05e03337 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -3,7 +3,7 @@ import * as InstanceState from "@/effect/instance-state" import { Session } from "@/session/session" import { Database } from "@/storage/db" import { SyncEvent } from "@/sync" -import { EventTable } from "@/sync/event.sql" +import { EventTable } from "@opencode-ai/core/event/sql" import { asc } from "drizzle-orm" import { and } from "drizzle-orm" import { eq } from "drizzle-orm" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index daa799b7a..eb71de0a8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -1,4 +1,4 @@ -import { SessionV2 } from "@/v2/session" +import { SessionV2 } from "@opencode-ai/core/session/index" import { Layer } from "effect" import { layer as v2LocationLayer } from "../groups/v2/location" import { messageHandlers } from "./v2/message" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index 9150ac43e..8f29940fb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -1,5 +1,5 @@ import { SessionMessage } from "@opencode-ai/core/session/message" -import { SessionV2 } from "@/v2/session" +import { SessionV2 } from "@opencode-ai/core/session/index" import { Effect, Schema } from "effect" import * as DateTime from "effect/DateTime" import { HttpApiBuilder } from "effect/unstable/httpapi" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index ff4e098fb..2ff8100f3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -1,5 +1,5 @@ import { WorkspaceID } from "@/control-plane/schema" -import { SessionV2 } from "@/v2/session" +import { SessionV2 } from "@opencode-ai/core/session/index" import { DateTime, Effect, Option, Schema } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" @@ -136,35 +136,6 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session } }), ) - .handle( - "prompt", - Effect.fn(function* (ctx) { - return yield* session - .prompt({ - sessionID: ctx.params.sessionID, - prompt: ctx.payload.prompt, - delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, - }) - .pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - Effect.catchTag("Session.OperationUnavailableError", (error) => - Effect.fail( - new ServiceUnavailableError({ - message: `V2 session ${error.operation} is not available yet`, - service: `v2.session.${error.operation}`, - }), - ), - ), - ) - }), - ) .handle( "compact", Effect.fn(function* (ctx) { diff --git a/packages/opencode/src/server/shared/fence.ts b/packages/opencode/src/server/shared/fence.ts index 770e4588b..346fb39a9 100644 --- a/packages/opencode/src/server/shared/fence.ts +++ b/packages/opencode/src/server/shared/fence.ts @@ -1,6 +1,6 @@ import { Database } from "@/storage/db" import { inArray } from "drizzle-orm" -import { EventSequenceTable } from "@/sync/event.sql" +import { EventSequenceTable } from "@opencode-ai/core/event/sql" import { Workspace } from "@/control-plane/workspace" import type { WorkspaceID } from "@/control-plane/schema" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2745ff4f4..9107cdb65 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -13,7 +13,7 @@ import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" import { or } from "drizzle-orm" -import { MessageTable, PartTable, SessionTable } from "./session.sql" +import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql" import * as ProviderError from "@/provider/error" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" @@ -579,14 +579,14 @@ export const cursor = { const info = (row: typeof MessageTable.$inferSelect) => ({ - ...row.data, + ...(row.data as object), id: row.id, sessionID: row.session_id, }) as Info const part = (row: typeof PartTable.$inferSelect) => ({ - ...row.data, + ...(row.data as object), id: row.id, sessionID: row.session_id, messageID: row.message_id, @@ -988,7 +988,7 @@ export function parts(message_id: MessageID) { return rows.map( (row) => ({ - ...row.data, + ...(row.data as object), id: row.id, sessionID: row.session_id, messageID: row.message_id, diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index d7b2d1482..3b808c64d 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -6,7 +6,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import * as DateTime from "effect/DateTime" import { SyncEvent } from "@/sync" import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionMessageTable, SessionTable } from "./session.sql" +import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import type { SessionID } from "./schema" import { Schema } from "effect" @@ -35,7 +35,7 @@ function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdate .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant"))) .orderBy(desc(SessionMessageTable.id)) .all() - .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .map((row) => decodeMessage({ ...(row.data as object), id: row.id, type: row.type })) .find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed) }, getCurrentCompaction() { @@ -45,7 +45,7 @@ function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdate .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .orderBy(desc(SessionMessageTable.id)) .all() - .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .map((row) => decodeMessage({ ...(row.data as object), id: row.id, type: row.type })) .find((message): message is SessionMessage.Compaction => message.type === "compaction") }, getCurrentShell(callID) { @@ -55,7 +55,7 @@ function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdate .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell"))) .orderBy(desc(SessionMessageTable.id)) .all() - .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .map((row) => decodeMessage({ ...(row.data as object), id: row.id, type: row.type })) .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) }, updateAssistant(assistant) { diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 3dd848c5b..77c1c1923 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -6,8 +6,8 @@ import type { TxOrDb } from "@/storage/db" import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" -import { SessionTable, MessageTable, PartTable } from "./session.sql" -import { WorkspaceTable } from "@/control-plane/workspace.sql" +import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { Log } from "@opencode-ai/core/util/log" import nextProjectors from "./projectors-next" @@ -23,10 +23,12 @@ export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial -function usage(part: MessageV2.Part | (typeof PartTable.$inferSelect)["data"]): Usage | undefined { - if (part.type !== "step-finish") return undefined - if (!("cost" in part) || !("tokens" in part)) return undefined - return { cost: part.cost, tokens: part.tokens } +function usage(part: MessageV2.Part | unknown): Usage | undefined { + if (typeof part !== "object" || part === null) return undefined + const value = part as Record + if (value.type !== "step-finish") return undefined + if (!("cost" in value) || !("tokens" in value)) return undefined + return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] } } function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sign = 1) { @@ -132,9 +134,9 @@ export default [ id, session_id: sessionID, time_created, - data: rest, + data: rest as never, }) - .onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } }) + .onConflictDoUpdate({ target: MessageTable.id, set: { data: rest as never } }) .run() } catch (err) { if (!foreign(err)) throw err diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a2d19e146..078cd3d0e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -56,7 +56,7 @@ import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" import * as Database from "@/storage/db" -import { SessionTable } from "./session.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { referencePromptMetadata, referenceTextPart } from "./prompt/reference" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index aa3a1e28d..e889a61ce 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { Session as CoreSession } from "@opencode-ai/core/session/index" +import { SessionV2 } from "@opencode-ai/core/session/index" import { withStatics } from "@opencode-ai/core/schema" -export const SessionID = CoreSession.ID +export const SessionID = SessionV2.ID export type SessionID = Schema.Schema.Type export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts deleted file mode 100644 index b1f40dcf1..000000000 --- a/packages/opencode/src/session/session.sql.ts +++ /dev/null @@ -1,137 +0,0 @@ -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 { Snapshot } from "../snapshot" -import type { Permission } from "../permission" -import type { ProjectID } from "../project/schema" -import type { SessionID, MessageID, PartID } from "./schema" -import type { WorkspaceID } from "../control-plane/schema" -import { Timestamps } from "../storage/schema.sql" - -type PartData = Omit -type InfoData = T extends unknown ? Omit : never -type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> - -export const SessionTable = sqliteTable( - "session", - { - id: text().$type().primaryKey(), - project_id: text() - .$type() - .notNull() - .references(() => ProjectTable.id, { onDelete: "cascade" }), - workspace_id: text().$type(), - parent_id: text().$type(), - 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" }).$type(), - 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" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), - permission: text({ mode: "json" }).$type(), - agent: text(), - model: text({ mode: "json" }).$type<{ - id: string - providerID: string - variant?: string - }>(), - ...Timestamps, - 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), - ], -) - -export const MessageTable = sqliteTable( - "message", - { - id: text().$type().primaryKey(), - session_id: text() - .$type() - .notNull() - .references(() => SessionTable.id, { onDelete: "cascade" }), - ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), - }, - (table) => [index("message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id)], -) - -export const PartTable = sqliteTable( - "part", - { - id: text().$type().primaryKey(), - message_id: text() - .$type() - .notNull() - .references(() => MessageTable.id, { onDelete: "cascade" }), - session_id: text().$type().notNull(), - ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), - }, - (table) => [ - index("part_message_id_id_idx").on(table.message_id, table.id), - index("part_session_idx").on(table.session_id), - ], -) - -export const TodoTable = sqliteTable( - "todo", - { - session_id: text() - .$type() - .notNull() - .references(() => SessionTable.id, { onDelete: "cascade" }), - content: text().notNull(), - status: text().notNull(), - priority: text().notNull(), - position: integer().notNull(), - ...Timestamps, - }, - (table) => [ - primaryKey({ columns: [table.session_id, table.position] }), - index("todo_session_idx").on(table.session_id), - ], -) - -export const SessionMessageTable = sqliteTable( - "session_message", - { - id: text().$type().primaryKey(), - session_id: text() - .$type() - .notNull() - .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().$type().notNull(), - ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), - }, - (table) => [ - index("session_message_session_idx").on(table.session_id), - index("session_message_session_type_idx").on(table.session_id, table.type), - index("session_message_time_created_idx").on(table.time_created), - ], -) - -export const PermissionTable = sqliteTable("permission", { - project_id: text() - .primaryKey() - .references(() => ProjectTable.id, { onDelete: "cascade" }), - ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), -}) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 0131d4438..79d493288 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -21,8 +21,8 @@ import { lt } from "drizzle-orm" import { or } from "drizzle-orm" import { SyncEvent } from "../sync" import type { SQL } from "drizzle-orm" -import { PartTable, SessionTable } from "./session.sql" -import { ProjectTable } from "../project/project.sql" +import { PartTable, SessionTable } from "@opencode-ai/core/session/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { Storage } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "./message-v2" @@ -647,7 +647,7 @@ export const layer: Layer.Layer< ) if (!row) return return { - ...row.data, + ...(row.data as object), id: row.id, sessionID: row.session_id, messageID: row.message_id, diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 005b3b7c4..77d43e8c7 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -5,7 +5,7 @@ import { Effect, Layer, Context, Schema } from "effect" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import { asc } from "drizzle-orm" -import { TodoTable } from "./session.sql" +import { TodoTable } from "@opencode-ai/core/session/sql" export const Info = Schema.Struct({ content: Schema.String.annotate({ description: "Brief description of the task" }), diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 98d5c8225..a3f91a52f 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -14,7 +14,7 @@ import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" -import { SessionShareTable } from "./share.sql" +import { SessionShareTable } from "@opencode-ai/core/share/sql" const log = Log.create({ service: "share-next" }) const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 06f1f84a9..3d8c172e8 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -1,5 +1,3 @@ -import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" -import { migrate } from "drizzle-orm/bun-sqlite/migrator" import { type SQLiteTransaction } from "drizzle-orm/sqlite-core" export * from "drizzle-orm" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -8,15 +6,12 @@ import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import path from "path" -import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationChannel } from "@opencode-ai/core/installation/version" import { EffectBridge } from "@/effect/bridge" import { init } from "#db" import { Effect, Schema } from "effect" -declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined - export const NotFoundError = NamedError.create("NotFoundError", { message: Schema.String, }) @@ -47,48 +42,6 @@ export type Transaction = SQLiteTransaction<"sync", void> type Client = ReturnType -type Journal = { sql: string; timestamp: number; name: string }[] - -// Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use. -const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void - -function applyMigrations(db: SQLiteBunDatabase, entries: Journal) { - migrateFromJournal(db, entries) -} - -function time(tag: string) { - const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) - if (!match) return 0 - return Date.UTC( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ) -} - -function migrations(dir: string): Journal { - const dirs = readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - - const sql = dirs - .map((name) => { - const file = path.join(dir, name, "migration.sql") - if (!existsSync(file)) return - return { - sql: readFileSync(file, "utf-8"), - timestamp: time(name), - name, - } - }) - .filter(Boolean) as Journal - - return sql.sort((a, b) => a.timestamp - b.timestamp) -} - let client: Client | undefined let loaded = false @@ -108,24 +61,6 @@ export const Client = Object.assign( db.run("PRAGMA foreign_keys = ON") db.run("PRAGMA wal_checkpoint(PASSIVE)") - // Apply schema migrations - const entries = - typeof OPENCODE_MIGRATIONS !== "undefined" - ? OPENCODE_MIGRATIONS - : migrations(path.join(import.meta.dirname, "../../migration")) - if (entries.length > 0) { - log.info("applying migrations", { - count: entries.length, - mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", - }) - if (flags.skipMigrations) { - for (const item of entries) { - item.sql = "select 1;" - } - } - applyMigrations(db, entries) - } - client = db loaded = true return db diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 3930e591a..00a10e6d9 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -2,9 +2,9 @@ import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { ProjectTable } from "../project/project.sql" -import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" -import { SessionShareTable } from "../share/share.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql" +import { SessionShareTable } from "@opencode-ai/core/share/sql" import path from "path" import { existsSync } from "fs" import { Filesystem } from "@/util/filesystem" diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 0c12cee62..01d47fcb5 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -1,5 +1,5 @@ -export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql" -export { ProjectTable } from "../project/project.sql" -export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" -export { SessionShareTable } from "../share/share.sql" -export { WorkspaceTable } from "../control-plane/workspace.sql" +export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql" +export { ProjectTable } from "@opencode-ai/core/project/sql" +export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql" +export { SessionShareTable } from "@opencode-ai/core/share/sql" +export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index a8858255f..6bad90d2c 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -7,7 +7,7 @@ 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 "./event.sql" +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" @@ -328,11 +328,11 @@ function process( .run() tx.insert(EventTable) .values({ - id: event.id, + id: event.id as never, seq: event.seq, aggregate_id: event.aggregateID, type: versionedType(def.type, def.version), - data: event.data as Record, + data: event.data as never, }) .run() } diff --git a/packages/opencode/src/v2/provider-parity-checklist.md b/packages/opencode/src/v2/provider-parity-checklist.md deleted file mode 100644 index e3a599d8e..000000000 --- a/packages/opencode/src/v2/provider-parity-checklist.md +++ /dev/null @@ -1,95 +0,0 @@ -# Unported Provider Logic Checklist - -This tracks legacy provider behavior from `packages/opencode/src/provider/provider.ts` that still needs to be ported into the v2 provider plugins under `packages/opencode/src/v2/plugin/provider/`. Keep entries checked only when v2 has equivalent behavior or when the item is intentionally skipped. - -## Provider Setup - -- [x] Cloudflare AI Gateway custom SDK construction with `createAiGateway` / `createUnified`. -- [x] Google Vertex authenticated `fetch` injection. -- [x] Amazon Bedrock AWS credential chain setup. -- [x] Amazon Bedrock bearer token setup. -- [x] SAP AI Core service key setup. - -## Provider Options - -- [x] Azure resource name resolution. -- [x] Azure missing-resource error. -- [x] Azure Cognitive Services baseURL resolution. -- [x] Cloudflare Workers AI account ID validation. -- [x] Cloudflare Workers AI account ID vars. -- [x] Cloudflare AI Gateway account ID validation. -- [x] Cloudflare AI Gateway gateway ID validation. -- [x] Cloudflare AI Gateway token validation. -- [x] Amazon Bedrock region precedence. -- [x] Amazon Bedrock profile precedence. -- [x] Amazon Bedrock endpoint precedence. -- [x] Google Vertex project resolution. -- [x] Google Vertex location resolution. -- [x] GitLab instance URL resolution. -- [x] GitLab token resolution. -- [x] GitLab AI gateway headers. -- [x] GitLab feature flags. -- [x] Opencode unauthenticated paid-model filtering. -- [x] Opencode public API key fallback. - -## Request Behavior - -- [x] Request timeout handling. -- [x] Chunk timeout handling. -- [x] SSE timeout wrapping. -- [x] OpenAI response item ID stripping. -- [x] Azure response item ID stripping. -- [x] OpenAI-compatible `includeUsage` defaulting. - -## Dynamic Models - -- [ ] GitLab workflow model discovery. - -## Model Filtering - -- [ ] Experimental alpha model filtering. -- [ ] Deprecated model filtering. -- [ ] Config whitelist filtering. -- [ ] Config blacklist filtering. -- [ ] `gpt-5-chat-latest` filtering. -- [ ] OpenRouter `openai/gpt-5-chat` filtering. - -## Default Models - -- [x] Configured default model selection. Replaced by explicit `Catalog.model.setDefault`. -- [SKIP] Recent-history default model selection — not porting to server-side v2 catalog. -- [x] Default model fallback sorting. Uses newest available model, not legacy hard-coded priority. - -## Small Models - -- [SKIP] Configured `small_model` selection — not porting config-driven selection to server-side v2 catalog. -- [x] Provider-specific small model priority. Replaced by cheapest output cost selection. -- [x] Opencode small model priority. Replaced by cheapest output cost selection. -- [x] GitHub Copilot small model priority. Replaced by cheapest output cost selection. -- [x] Amazon Bedrock region-aware small model selection. Replaced by cheapest output cost selection. - -## URL And Env Vars - -- [SKIP] BaseURL `${VAR}` interpolation — not porting generic URL templating; provider plugins should construct concrete URLs. -- [x] Azure `AZURE_RESOURCE_NAME` vars. Handled by Azure provider plugins. -- [x] Google Vertex vars. Handled by Google Vertex provider plugins. -- [x] Cloudflare Workers AI vars. Handled by Cloudflare Workers AI provider plugin. - -## Auth - -- [ ] Auth-derived provider API keys. -- [ ] OpenAI OAuth/API auth distinction. -- [ ] GitLab OAuth token selection. -- [ ] GitLab API token selection. -- [ ] Azure auth metadata resource name. -- [ ] Cloudflare auth metadata account ID. -- [ ] Cloudflare auth metadata gateway ID. - -## Config And Plugin Parity - -- [ ] Legacy plugin auth loader behavior. -- [ ] Config provider merge behavior. -- [ ] Config model merge behavior. -- [ ] Variant generation from model metadata. -- [ ] Config variant merge behavior. -- [ ] Config variant disable behavior. diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts deleted file mode 100644 index 8d8e2d0a3..000000000 --- a/packages/opencode/src/v2/session.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { SessionMessageTable, SessionTable } from "@/session/session.sql" -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 { ProjectID } from "@/project/schema" -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" -import { EventV2Bridge } from "@/event-v2-bridge" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" - -export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ - identifier: "Session.Delivery", -}) -export type Delivery = Schema.Schema.Type - -export const DefaultDelivery = "immediate" satisfies Delivery - -export class Info extends Schema.Class("Session.Info")({ - id: SessionID, - parentID: optionalOmitUndefined(SessionID), - projectID: ProjectID, - workspaceID: optionalOmitUndefined(WorkspaceID), - path: optionalOmitUndefined(Schema.String), - agent: optionalOmitUndefined(Schema.String), - model: ModelV2.Ref.pipe(optionalOmitUndefined), - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - time: Schema.Struct({ - created: V2Schema.DateTimeUtcFromMillis, - updated: V2Schema.DateTimeUtcFromMillis, - archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), - }), - title: Schema.String, - /* - slug: Schema.String, - directory: Schema.String, - path: optionalOmitUndefined(Schema.String), - parentID: optionalOmitUndefined(SessionID), - summary: optionalOmitUndefined(Summary), - share: optionalOmitUndefined(Share), - title: Schema.String, - version: Schema.String, - time: Time, - permission: optionalOmitUndefined(Permission.Ruleset), - revert: optionalOmitUndefined(Revert), - */ -}) {} - -export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { - sessionID: SessionID, -}) {} - -export class OperationUnavailableError extends Schema.TaggedErrorClass()( - "Session.OperationUnavailableError", - { - operation: Schema.Literals(["prompt", "compact", "wait"]), - }, -) {} - -export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { - sessionID: SessionID, - messageID: SessionMessage.ID, -}) {} - -export interface Interface { - readonly create: (input?: { - agent?: string - model?: ModelV2.Ref - parentID?: SessionID - workspaceID?: WorkspaceID - }) => Effect.Effect - readonly get: (sessionID: SessionID) => Effect.Effect - readonly list: (input: { - limit?: number - order?: "asc" | "desc" - projectID?: ProjectID - workspaceID?: WorkspaceID - path?: string - roots?: boolean - start?: number - search?: string - cursor?: { - id: SessionID - time: number - direction: "previous" | "next" - } - }) => Effect.Effect - readonly messages: (input: { - sessionID: SessionID - limit?: number - order?: "asc" | "desc" - cursor?: { - id: SessionMessage.ID - time: number - direction: "previous" | "next" - } - }) => Effect.Effect - readonly context: ( - sessionID: SessionID, - ) => Effect.Effect - readonly subagent: (input: { - id?: EventV2.ID - parentID: SessionID - prompt: Prompt - agent: string - model?: ModelV2.Ref - resume?: boolean - }) => Effect.Effect - readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect - readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect - readonly compact: (sessionID: SessionID) => Effect.Effect - readonly resume: (sessionID: SessionID) => Effect.Effect - readonly wait: (sessionID: SessionID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Session") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const events = yield* EventV2Bridge.Service - const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) - - const decode = (row: typeof SessionMessageTable.$inferSelect) => - decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( - Effect.mapError( - () => - new MessageDecodeError({ - sessionID: SessionID.make(row.session_id), - messageID: SessionMessage.ID.make(row.id), - }), - ), - ) - - function fromRow(row: typeof SessionTable.$inferSelect): Info { - return new Info({ - id: SessionID.make(row.id), - projectID: ProjectID.make(row.project_id), - workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, - title: row.title, - parentID: row.parent_id ? SessionID.make(row.parent_id) : undefined, - path: row.path ?? "", - agent: row.agent ?? undefined, - model: row.model - ? { - id: ModelV2.ID.make(row.model.id), - providerID: ProviderV2.ID.make(row.model.providerID), - variant: ModelV2.VariantID.make(row.model.variant ?? "default"), - } - : undefined, - cost: row.cost, - tokens: { - input: row.tokens_input, - output: row.tokens_output, - reasoning: row.tokens_reasoning, - cache: { - read: row.tokens_cache_read, - write: row.tokens_cache_write, - }, - }, - time: { - created: DateTime.makeUnsafe(row.time_created), - updated: DateTime.makeUnsafe(row.time_updated), - archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, - }, - }) - } - - const result = Service.of({ - create: Effect.fn("V2Session.create")(function* (_input) { - return {} as any - }), - get: Effect.fn("V2Session.get")(function* (sessionID) { - const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) - if (!row) return yield* new NotFoundError({ sessionID }) - return fromRow(row) - }), - list: Effect.fn("V2Session.list")(function* (input) { - const direction = input.cursor?.direction ?? "next" - let order = input.order ?? "desc" - // This is a load bearing sort, desktop relies on this - const sortColumn = SessionTable.time_updated - // Query the adjacent rows in reverse, then flip them back into the requested order below. - if (direction === "previous" && order === "asc") order = "desc" - if (direction === "previous" && order === "desc") order = "asc" - const conditions: SQL[] = [] - if (input.directory) conditions.push(eq(SessionTable.directory, input.directory)) - if (input.path) - conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) - if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) - if (input.roots) conditions.push(isNull(SessionTable.parent_id)) - if (input.start) conditions.push(gte(sortColumn, input.start)) - if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) - if (input.cursor) { - conditions.push( - order === "asc" - ? or( - gt(sortColumn, input.cursor.time), - and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)), - )! - : or( - lt(sortColumn, input.cursor.time), - and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)), - )!, - ) - } - const query = Database.Client() - .select() - .from(SessionTable) - .where(conditions.length > 0 ? and(...conditions) : undefined) - .orderBy( - order === "asc" ? asc(sortColumn) : desc(sortColumn), - order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), - ) - - const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() - return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) - }), - messages: Effect.fn("V2Session.messages")(function* (input) { - yield* result.get(input.sessionID) - const direction = input.cursor?.direction ?? "next" - let order = input.order ?? "desc" - // Query the adjacent rows in reverse, then flip them back into the requested order below. - if (direction === "previous" && order === "asc") order = "desc" - if (direction === "previous" && order === "desc") order = "asc" - const boundary = input.cursor - ? order === "asc" - ? or( - gt(SessionMessageTable.time_created, input.cursor.time), - and( - eq(SessionMessageTable.time_created, input.cursor.time), - gt(SessionMessageTable.id, input.cursor.id), - ), - ) - : or( - lt(SessionMessageTable.time_created, input.cursor.time), - and( - eq(SessionMessageTable.time_created, input.cursor.time), - lt(SessionMessageTable.id, input.cursor.id), - ), - ) - : undefined - const where = boundary - ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) - : eq(SessionMessageTable.session_id, input.sessionID) - - const rows = Database.use((db) => { - const query = db - .select() - .from(SessionMessageTable) - .where(where) - .orderBy( - order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created), - order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id), - ) - const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() - return direction === "previous" ? rows.toReversed() : rows - }) - return yield* Effect.forEach(rows, (row) => decode(row)) - }), - context: Effect.fn("V2Session.context")(function* (sessionID) { - yield* result.get(sessionID) - const rows = Database.use((db) => { - const compaction = db - .select() - .from(SessionMessageTable) - .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) - .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id)) - .limit(1) - .get() - - return db - .select() - .from(SessionMessageTable) - .where( - and( - eq(SessionMessageTable.session_id, sessionID), - compaction - ? or( - gt(SessionMessageTable.time_created, compaction.time_created), - and( - eq(SessionMessageTable.time_created, compaction.time_created), - gte(SessionMessageTable.id, compaction.id), - ), - ) - : undefined, - ), - ) - .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id)) - .all() - }) - return yield* Effect.forEach(rows, (row) => decode(row)) - }), - prompt: Effect.fn("V2Session.prompt")(function* (input) { - yield* result.get(input.sessionID) - return yield* new OperationUnavailableError({ operation: "prompt" }) - }), - shell: Effect.fn("V2Session.shell")(function* (_input) {}), - skill: Effect.fn("V2Session.skill")(function* (_input) {}), - switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - agent: input.agent, - }) - }), - switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - model: input.model, - }) - }), - subagent: Effect.fn("V2Session.subagent")(function* (input) { - const parent = yield* result.get(input.parentID) - const child = yield* result.create({ - agent: input.agent, - model: input.model, - parentID: input.parentID, - workspaceID: parent.workspaceID, - }) - yield* result.prompt({ - prompt: input.prompt, - sessionID: child.id, - }) - yield* Effect.gen(function* () { - yield* result.wait(child.id) - const messages = yield* result.messages({ sessionID: child.id, order: "desc" }) - const assistant = messages.find((msg) => msg.type === "assistant") - if (!assistant) return - const text = assistant.content.findLast((part) => part.type === "text") - if (!text) return - }).pipe(Effect.forkChild()) - }), - compact: Effect.fn("V2Session.compact")(function* (sessionID) { - yield* result.get(sessionID) - return yield* new OperationUnavailableError({ operation: "compact" }) - }), - wait: Effect.fn("V2Session.wait")(function* (sessionID) { - yield* result.get(sessionID) - return yield* new OperationUnavailableError({ operation: "wait" }) - }), - }) - - return result - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) - -export * as SessionV2 from "./session" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index a1d4f89c2..c78598d6c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -4,7 +4,7 @@ import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { ProjectTable } from "../project/project.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" import type { ProjectID } from "../project/schema" import * as Log from "@opencode-ai/core/util/log" import { Slug } from "@opencode-ai/core/util/slug" diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 09810d57d..f038734b6 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -12,18 +12,18 @@ import * as Log from "@opencode-ai/core/util/log" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Database } from "@/storage/db" import { ProjectID } from "@/project/schema" -import { ProjectTable } from "@/project/project.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { Session as SessionNs } from "@/session/session" import { SessionID } from "@/session/schema" -import { SessionTable } from "@/session/session.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { SyncEvent } from "@/sync" -import { EventSequenceTable } from "@/sync/event.sql" +import { EventSequenceTable } from "@opencode-ai/core/event/sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideTmpdirInstance, requireInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" -import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import * as Workspace from "../../src/control-plane/workspace" import { InstanceStore } from "@/project/instance-store" diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 6efd670c5..d1ee73c42 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -2,8 +2,8 @@ import { describe, expect } from "bun:test" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { SessionTable } from "../../src/session/session.sql" -import { ProjectTable } from "../../src/project/project.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectID } from "../../src/project/schema" import { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index aa7e4946d..06f2ec554 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -5,7 +5,7 @@ import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" -import { SessionTable } from "@/session/session.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { Database } from "@/storage/db" import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index c221bdd19..007a5c5a2 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -8,7 +8,7 @@ import { Session } from "@/session/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { MessageID, PartID } from "../../src/session/schema" -import { PartTable } from "@/session/session.sql" +import { PartTable } from "@opencode-ai/core/session/sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 9c9cbd1e6..0f7994ab3 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -19,7 +19,7 @@ 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 { SessionMessageTable, SessionTable } from "@/session/session.sql" +import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 02a1361ba..14d29807e 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -18,7 +18,7 @@ import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" -import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { Project } from "../../src/project/project" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts index 290023ead..7cfa6da6d 100644 --- a/packages/opencode/test/server/negative-tokens-regression.test.ts +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -14,7 +14,7 @@ import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/se import { Session } from "@/session/session" import { MessageID, PartID } from "../../src/session/schema" import * as Database from "@/storage/db" -import { PartTable } from "@/session/session.sql" +import { PartTable } from "@opencode-ai/core/session/sql" import { resetDatabase } from "../fixture/db" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 467ab7c9a..ae61cc5fa 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -6,7 +6,7 @@ import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/f import { mkdir } from "fs/promises" import path from "path" import { Database } from "@/storage/db" -import { SessionTable } from "@/session/session.sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { eq } from "drizzle-orm" import { testEffect } from "../lib/effect" import { Bus } from "@/bus" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 55ddc621c..3c0398e8f 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -18,7 +18,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" -import { SessionV2 } from "../../src/v2/session" +import { SessionV2 } from "@opencode-ai/core/session/index" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ff9ded4d1..6d28f20d7 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -22,7 +22,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" -import { SessionMessageTable } from "../../src/session/session.sql" +import { SessionMessageTable } from "@opencode-ai/core/session/sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -35,7 +35,7 @@ import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" -import { SessionV2 } from "../../src/v2/session" +import { SessionV2 } from "@opencode-ai/core/session/index" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" @@ -506,7 +506,7 @@ noLLMServer.instance( }) const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( - Effect.provide(SessionV2.layer), + Effect.provide(SessionV2.defaultLayer), ) const row = Database.use((db) => db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(), diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 1daa4c2c8..b7f40c191 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -13,7 +13,7 @@ import { Provider } from "@/provider/provider" import { Session } from "@/session/session" import type { SessionID } from "../../src/session/schema" import { ShareNext } from "@/share/share-next" -import { SessionShareTable } from "../../src/share/share.sql" +import { SessionShareTable } from "@opencode-ai/core/share/sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import { provideTmpdirInstance } from "../fixture/fixture" diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index 598a635cd..85bcded17 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -7,10 +7,10 @@ import fs from "fs/promises" import { readFileSync, readdirSync } from "fs" import { JsonMigration } from "@/storage/json-migration" import { Global } from "@opencode-ai/core/global" -import { ProjectTable } from "../../src/project/project.sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectID } from "../../src/project/schema" -import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql" -import { SessionShareTable } from "../../src/share/share.sql" +import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql" +import { SessionShareTable } from "@opencode-ai/core/share/sql" import { SessionID, MessageID, PartID } from "../../src/session/schema" // Test fixtures @@ -79,7 +79,7 @@ function createTestDb() { sqlite.exec("PRAGMA foreign_keys = ON") // Apply schema migrations using drizzle migrate - const dir = path.join(import.meta.dirname, "../../migration") + const dir = path.join(import.meta.dirname, "../../../core/migration") const entries = readdirSync(dir, { withFileTypes: true }) const migrations = entries .filter((entry) => entry.isDirectory()) diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts index 2d3064697..e6b537bfb 100644 --- a/packages/opencode/test/storage/workspace-time-migration.test.ts +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -8,12 +8,12 @@ import path from "path" const target = "20260507164347_add_workspace_time" function migrations() { - return readdirSync(path.join(import.meta.dirname, "../../migration"), { withFileTypes: true }) + return readdirSync(path.join(import.meta.dirname, "../../../core/migration"), { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => ({ name: entry.name, timestamp: Number(entry.name.split("_")[0]), - sql: readFileSync(path.join(import.meta.dirname, "../../migration", entry.name, "migration.sql"), "utf-8"), + sql: readFileSync(path.join(import.meta.dirname, "../../../core/migration", entry.name, "migration.sql"), "utf-8"), })) .sort((a, b) => a.timestamp - b.timestamp) } diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index e3307d2ae..46de313cb 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -6,7 +6,8 @@ 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 "../../src/sync/event.sql" +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" @@ -358,7 +359,7 @@ describe("SyncEvent", () => { .get(), ) expect(events).toHaveLength(1) - expect(events[0].id).toBe("evt_1") + expect(events[0]?.id).toBe(EventV2.ID.make("evt_1")) expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" }) }), ),