refactor(core): move database schema ownership
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]
|
||||
`
|
||||
}
|
||||
@@ -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<OAuthCredential>("AccountV2.OAuthCredential")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: NonNegativeInt,
|
||||
}) {}
|
||||
|
||||
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("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<typeof Credential>
|
||||
|
||||
export class Info extends Schema.Class<Info>("AccountV2.Info")({
|
||||
id: ID,
|
||||
serviceID: ServiceID,
|
||||
description: Schema.String,
|
||||
credential: Credential,
|
||||
}) {}
|
||||
|
||||
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("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<string, Info>
|
||||
active: Record<string, ID>
|
||||
}
|
||||
|
||||
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
|
||||
|
||||
function migrate(old: Record<string, unknown>): Writable {
|
||||
const accounts: Record<string, Info> = {}
|
||||
const active: Record<string, ID> = {}
|
||||
for (const [serviceID, value] of Object.entries(old)) {
|
||||
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
|
||||
const parsed = (decoded as Record<string, Credential>)[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<Info | undefined, Error>
|
||||
readonly all: () => Effect.Effect<Info[], Error>
|
||||
readonly create: (input: {
|
||||
serviceID: ServiceID
|
||||
credential: Credential
|
||||
description?: string
|
||||
}) => Effect.Effect<Info | undefined, Error>
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
|
||||
readonly remove: (id: ID) => Effect.Effect<void, Error>
|
||||
readonly activate: (id: ID) => Effect.Effect<void, Error>
|
||||
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
|
||||
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<string, unknown>) {
|
||||
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<Writable, Error> = 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<string, unknown>)
|
||||
}
|
||||
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<string, unknown>)
|
||||
|
||||
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<string, unknown>)
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -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<typeof ID>
|
||||
|
||||
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
|
||||
export type OrgID = Schema.Schema.Type<typeof OrgID>
|
||||
|
||||
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
|
||||
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
||||
|
||||
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
|
||||
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
|
||||
|
||||
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
|
||||
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
|
||||
|
||||
export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
|
||||
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||
|
||||
export class Info extends Schema.Class<Info>("Account")({
|
||||
id: ID,
|
||||
email: Schema.String,
|
||||
url: Schema.String,
|
||||
active_org_id: Schema.NullOr(OrgID),
|
||||
}) {}
|
||||
|
||||
export class Org extends Schema.Class<Org>("Org")({
|
||||
id: OrgID,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("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>("Login")({
|
||||
code: DeviceCode,
|
||||
user: UserCode,
|
||||
url: Schema.String,
|
||||
server: Schema.String,
|
||||
expiry: Schema.Duration,
|
||||
interval: Schema.Duration,
|
||||
}) {}
|
||||
|
||||
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
|
||||
|
||||
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
|
||||
|
||||
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
|
||||
|
||||
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
|
||||
|
||||
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
|
||||
export type PollResult = Schema.Schema.Type<typeof PollResult>
|
||||
@@ -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<AccountID>().primaryKey(),
|
||||
id: text().$type<AccountV2.ID>().primaryKey(),
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<AccessToken>().notNull(),
|
||||
refresh_token: text().$type<RefreshToken>().notNull(),
|
||||
access_token: text().$type<AccountV2.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<AccountV2.RefreshToken>().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<AccountID>()
|
||||
.$type<AccountV2.ID>()
|
||||
.references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
active_org_id: text().$type<OrgID>(),
|
||||
active_org_id: text().$type<AccountV2.OrgID>(),
|
||||
})
|
||||
|
||||
// LEGACY
|
||||
@@ -27,8 +27,8 @@ export const ControlAccountTable = sqliteTable(
|
||||
{
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<AccessToken>().notNull(),
|
||||
refresh_token: text().$type<RefreshToken>().notNull(),
|
||||
access_token: text().$type<AccountV2.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
|
||||
token_expiry: integer(),
|
||||
active: integer({ mode: "boolean" })
|
||||
.notNull()
|
||||
@@ -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<OAuthCredential>("Auth.OAuthCredential")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: NonNegativeInt,
|
||||
}) {}
|
||||
|
||||
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("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<typeof Credential>
|
||||
|
||||
export class Info extends Schema.Class<Info>("Auth.Info")({
|
||||
id: ID,
|
||||
serviceID: ServiceID,
|
||||
description: Schema.String,
|
||||
credential: Credential,
|
||||
}) {}
|
||||
|
||||
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("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<string, Info>
|
||||
active: Record<string, ID>
|
||||
}
|
||||
|
||||
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
|
||||
|
||||
function migrate(old: Record<string, unknown>): Writable {
|
||||
const accounts: Record<string, Info> = {}
|
||||
const active: Record<string, ID> = {}
|
||||
for (const [serviceID, value] of Object.entries(old)) {
|
||||
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
|
||||
const parsed = (decoded as Record<string, Credential>)[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<Info | undefined, Error>
|
||||
readonly all: () => Effect.Effect<Info[], Error>
|
||||
readonly create: (input: {
|
||||
serviceID: ServiceID
|
||||
credential: Credential
|
||||
description?: string
|
||||
}) => Effect.Effect<Info | undefined, Error>
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
|
||||
readonly remove: (id: ID) => Effect.Effect<void, Error>
|
||||
readonly activate: (id: ID) => Effect.Effect<void, Error>
|
||||
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
|
||||
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<string, unknown>) {
|
||||
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<Writable, Error> = 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<string, unknown>)
|
||||
}
|
||||
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<string, unknown>)
|
||||
|
||||
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<string, unknown>)
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
+5
-5
@@ -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<WorkspaceID>().primaryKey(),
|
||||
id: text().$type<WorkspaceV2.ID>().primaryKey(),
|
||||
type: text().notNull(),
|
||||
name: text().notNull().default(""),
|
||||
branch: text(),
|
||||
directory: text(),
|
||||
extra: text({ mode: "json" }),
|
||||
project_id: text()
|
||||
.$type<ProjectID>()
|
||||
.$type<Project.ID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
time_used: integer()
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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[]
|
||||
@@ -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<string, TableAst>
|
||||
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
|
||||
|
||||
export type Migration = {
|
||||
id: string
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export type TableAst = {
|
||||
name: string
|
||||
columns: Record<string, ColumnAst>
|
||||
indexes: Record<string, IndexAst>
|
||||
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<Operation>((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('"', '""')}"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+2
-157
@@ -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<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
||||
readonly type: Type
|
||||
readonly version?: number
|
||||
readonly aggregate?: string
|
||||
readonly data: DataSchema
|
||||
}
|
||||
|
||||
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
|
||||
|
||||
export type Payload<D extends Definition = Definition> = {
|
||||
readonly id: ID
|
||||
readonly type: D["type"]
|
||||
readonly data: Data<D>
|
||||
readonly version?: number
|
||||
readonly location?: Location.Ref
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Sync = (event: Payload) => Effect.Effect<void>
|
||||
|
||||
export const registry = new Map<string, Definition>()
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
readonly type: Type
|
||||
readonly version?: number
|
||||
readonly aggregate?: string
|
||||
readonly schema: Fields
|
||||
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
|
||||
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<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||
Definition<Type, Schema.Struct<Fields>>
|
||||
}
|
||||
|
||||
export function definitions() {
|
||||
return registry.values().toArray()
|
||||
}
|
||||
|
||||
export interface PublishOptions {
|
||||
readonly id?: ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends Definition>(
|
||||
definition: D,
|
||||
data: Data<D>,
|
||||
options?: PublishOptions,
|
||||
) => Effect.Effect<Payload<D>>
|
||||
readonly publishEvent: <D extends Definition>(event: Payload<D>) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const syncHandlers = new Array<Sync>()
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = typed.get(definition.type)
|
||||
if (existing) return existing
|
||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
||||
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<D extends Definition>(event: Payload<D>) {
|
||||
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<D extends Definition>(definition: D, data: Data<D>, 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<D>
|
||||
return yield* publishEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
||||
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
||||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
|
||||
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"
|
||||
|
||||
@@ -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<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
||||
readonly type: Type
|
||||
readonly version?: number
|
||||
readonly aggregate?: string
|
||||
readonly data: DataSchema
|
||||
}
|
||||
|
||||
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
|
||||
|
||||
export type Payload<D extends Definition = Definition> = {
|
||||
readonly id: ID
|
||||
readonly type: D["type"]
|
||||
readonly data: Data<D>
|
||||
readonly version?: number
|
||||
readonly location?: Location.Ref
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Sync = (event: Payload) => Effect.Effect<void>
|
||||
|
||||
export const registry = new Map<string, Definition>()
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
readonly type: Type
|
||||
readonly version?: number
|
||||
readonly aggregate?: string
|
||||
readonly schema: Fields
|
||||
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
|
||||
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<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||
Definition<Type, Schema.Struct<Fields>>
|
||||
}
|
||||
|
||||
export function definitions() {
|
||||
return registry.values().toArray()
|
||||
}
|
||||
|
||||
export interface PublishOptions {
|
||||
readonly id?: ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends Definition>(
|
||||
definition: D,
|
||||
data: Data<D>,
|
||||
options?: PublishOptions,
|
||||
) => Effect.Effect<Payload<D>>
|
||||
readonly publishEvent: <D extends Definition>(event: Payload<D>) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const syncHandlers = new Array<Sync>()
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = typed.get(definition.type)
|
||||
if (existing) return existing
|
||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
||||
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<D extends Definition>(event: Payload<D>) {
|
||||
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<D extends Definition>(definition: D, data: Data<D>, 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<D>
|
||||
return yield* publishEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
||||
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
||||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
|
||||
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
|
||||
@@ -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<EventV2.ID>().primaryKey(),
|
||||
aggregate_id: text()
|
||||
.notNull()
|
||||
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
|
||||
@@ -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"
|
||||
@@ -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<string> {
|
||||
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"
|
||||
|
||||
@@ -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>()(
|
||||
"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<string> {
|
||||
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"
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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: {}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<void>()
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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<ID>
|
||||
readonly locations: (projectID: ID) => Promise<Location.Ref[]>
|
||||
// opencode -> ["~/dev/projects/anomalyco/opencode", "~/.gitworktrees/anomalyci/opencode"]
|
||||
// global -> ["~/.config/nvim", "/etc/nixos"]
|
||||
|
||||
readonly resolve: (input: AbsolutePath) => Promise<ID>
|
||||
// ~/dev/projects/anomalyco/opencode -> opencode
|
||||
// ~/dev/projects/anomalyco/opencode/packages/core -> opencode
|
||||
// ~/.gitworktrees/anomalyci/opencode -> opencode
|
||||
// ~/.config/nvim -> global
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||
|
||||
interface GitResult {
|
||||
readonly exitCode: number
|
||||
readonly text: () => string
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
|
||||
const runGit = Effect.fn("Project.git")(
|
||||
function* (args: string[], cwd: string) {
|
||||
const result = yield* proc.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
}),
|
||||
)
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
text: () => result.stdout.toString("utf8"),
|
||||
} satisfies GitResult
|
||||
},
|
||||
Effect.catch(() =>
|
||||
Effect.succeed({
|
||||
exitCode: 1,
|
||||
text: () => "",
|
||||
} satisfies GitResult),
|
||||
),
|
||||
)
|
||||
|
||||
const resolveGitPath = (cwd: string, value: string) => {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
const normalized = AppFileSystem.windowsPath(trimmed)
|
||||
if (path.isAbsolute(normalized)) return path.normalize(normalized)
|
||||
return path.resolve(cwd, normalized)
|
||||
}
|
||||
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map((x) => ID.make(x)),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
})
|
||||
|
||||
const resolve = async (input: AbsolutePath) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const repoPath = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
if (!repoPath) return ID.global
|
||||
|
||||
const cwd = path.dirname(repoPath)
|
||||
const parsed = yield* runGit(["rev-parse", "--git-dir", "--git-common-dir"], cwd)
|
||||
if (parsed.exitCode !== 0) return (yield* readCachedProjectId(repoPath)) ?? ID.global
|
||||
|
||||
const gitPaths = parsed
|
||||
.text()
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const commonDir = gitPaths[1] ? resolveGitPath(cwd, gitPaths[1]) : undefined
|
||||
if (!commonDir) return (yield* readCachedProjectId(repoPath)) ?? ID.global
|
||||
|
||||
const cached = (yield* readCachedProjectId(repoPath)) ?? (yield* readCachedProjectId(commonDir))
|
||||
if (cached) return cached
|
||||
|
||||
const id = (yield* runGit(["rev-list", "--max-parents=0", "HEAD"], cwd))
|
||||
.text()
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.toSorted()[0]
|
||||
|
||||
if (!id) return ID.global
|
||||
yield* fs.writeFileString(path.join(commonDir, "opencode"), id).pipe(Effect.ignore)
|
||||
return ID.make(id)
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
create: async () => {
|
||||
throw new Error("Project.create is not implemented")
|
||||
},
|
||||
locations: async () => [],
|
||||
resolve,
|
||||
})
|
||||
}),
|
||||
)
|
||||
export * from "./project/index"
|
||||
export * as Project from "./project/index"
|
||||
|
||||
@@ -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<ID>
|
||||
readonly locations: (projectID: ID) => Promise<Location.Ref[]>
|
||||
// opencode -> ["~/dev/projects/anomalyco/opencode", "~/.gitworktrees/anomalyci/opencode"]
|
||||
// global -> ["~/.config/nvim", "/etc/nixos"]
|
||||
|
||||
readonly resolve: (input: AbsolutePath) => Promise<ID>
|
||||
// ~/dev/projects/anomalyco/opencode -> opencode
|
||||
// ~/dev/projects/anomalyco/opencode/packages/core -> opencode
|
||||
// ~/.gitworktrees/anomalyci/opencode -> opencode
|
||||
// ~/.config/nvim -> global
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||
|
||||
interface GitResult {
|
||||
readonly exitCode: number
|
||||
readonly text: () => string
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
|
||||
const runGit = Effect.fn("Project.git")(
|
||||
function* (args: string[], cwd: string) {
|
||||
const result = yield* proc.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
}),
|
||||
)
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
text: () => result.stdout.toString("utf8"),
|
||||
} satisfies GitResult
|
||||
},
|
||||
Effect.catch(() =>
|
||||
Effect.succeed({
|
||||
exitCode: 1,
|
||||
text: () => "",
|
||||
} satisfies GitResult),
|
||||
),
|
||||
)
|
||||
|
||||
const resolveGitPath = (cwd: string, value: string) => {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
const normalized = AppFileSystem.windowsPath(trimmed)
|
||||
if (path.isAbsolute(normalized)) return path.normalize(normalized)
|
||||
return path.resolve(cwd, normalized)
|
||||
}
|
||||
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map((x) => ID.make(x)),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
})
|
||||
|
||||
const resolve = async (input: AbsolutePath) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const repoPath = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
if (!repoPath) return ID.global
|
||||
|
||||
const cwd = path.dirname(repoPath)
|
||||
const parsed = yield* runGit(["rev-parse", "--git-dir", "--git-common-dir"], cwd)
|
||||
if (parsed.exitCode !== 0) return (yield* readCachedProjectId(repoPath)) ?? ID.global
|
||||
|
||||
const gitPaths = parsed
|
||||
.text()
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const commonDir = gitPaths[1] ? resolveGitPath(cwd, gitPaths[1]) : undefined
|
||||
if (!commonDir) return (yield* readCachedProjectId(repoPath)) ?? ID.global
|
||||
|
||||
const cached = (yield* readCachedProjectId(repoPath)) ?? (yield* readCachedProjectId(commonDir))
|
||||
if (cached) return cached
|
||||
|
||||
const id = (yield* runGit(["rev-list", "--max-parents=0", "HEAD"], cwd))
|
||||
.text()
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.toSorted()[0]
|
||||
|
||||
if (!id) return ID.global
|
||||
yield* fs.writeFileString(path.join(commonDir, "opencode"), id).pipe(Effect.ignore)
|
||||
return ID.make(id)
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
create: async () => {
|
||||
throw new Error("Project.create is not implemented")
|
||||
},
|
||||
locations: async () => [],
|
||||
resolve,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -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<ProjectID>().primaryKey(),
|
||||
id: text().$type<Project.ID>().primaryKey(),
|
||||
worktree: text().notNull(),
|
||||
vcs: text(),
|
||||
name: text(),
|
||||
@@ -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<Info>("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"
|
||||
|
||||
@@ -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<Info>("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: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<typeof Delivery>
|
||||
|
||||
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<Info>("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<NotFoundError>()("Ses
|
||||
sessionID: ID,
|
||||
}) {}
|
||||
|
||||
export type Error = NotFoundError
|
||||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
||||
"Session.OperationUnavailableError",
|
||||
{
|
||||
operation: Schema.Literals(["prompt", "compact", "wait"]),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
sessionID: ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export type Error = NotFoundError | OperationUnavailableError | MessageDecodeError
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||
@@ -96,8 +145,16 @@ export interface Interface {
|
||||
time: number
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||
readonly context: (sessionID: ID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
readonly context: (sessionID: ID) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
readonly subagent: (input: {
|
||||
id?: EventV2.ID
|
||||
parentID: ID
|
||||
prompt: Prompt
|
||||
agent: string
|
||||
model?: ModelV2.Ref
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError | OperationUnavailableError | MessageDecodeError>
|
||||
readonly switchAgent: (input: { sessionID: ID; agent: string }) => Effect.Effect<void, never>
|
||||
readonly switchModel: (input: { sessionID: ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
|
||||
readonly prompt: (input: {
|
||||
@@ -106,7 +163,7 @@ export interface Interface {
|
||||
prompt: Prompt
|
||||
delivery?: Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
}) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly shell: (input: {
|
||||
id?: EventV2.ID
|
||||
sessionID: ID
|
||||
@@ -121,7 +178,191 @@ export interface Interface {
|
||||
delivery?: Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, never>
|
||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly wait: (id: ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly compact: (input: CompactInput | ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly resume: (sessionID: ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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)
|
||||
|
||||
@@ -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
|
||||
@@ -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<typeof Message>
|
||||
|
||||
export type Type = Message["type"]
|
||||
|
||||
export * as SessionMessage from "./message"
|
||||
|
||||
@@ -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<Session.ID>().primaryKey(),
|
||||
project_id: text().notNull(),
|
||||
workspace_id: text(),
|
||||
parent_id: text().$type<Session.ID>(),
|
||||
id: text().$type<ID>().primaryKey(),
|
||||
project_id: text()
|
||||
.$type<Project.ID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
workspace_id: text().$type<WorkspaceV2.ID>(),
|
||||
parent_id: text().$type<ID>(),
|
||||
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<Snapshot.FileDiff[]>(),
|
||||
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<PermissionV2.Ruleset>(),
|
||||
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<MessageID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<ID>()
|
||||
.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<PartID>().primaryKey(),
|
||||
message_id: text()
|
||||
.$type<MessageID>()
|
||||
.notNull()
|
||||
.references(() => MessageTable.id, { onDelete: "cascade" }),
|
||||
session_id: text().$type<ID>().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<ID>()
|
||||
.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<SessionMessage.ID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
type: text().$type<SessionMessage.Type>().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
|
||||
},
|
||||
(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<PermissionV2.Ruleset>(),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,9 @@
|
||||
export namespace Snapshot {
|
||||
export type FileDiff = {
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||
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<DatabaseMigration.Operation>((table) => {
|
||||
const actual = actualTables.find((item) => item.name === table.name)
|
||||
if (!actual) {
|
||||
return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)]
|
||||
}
|
||||
return [
|
||||
...Object.values(table.columns)
|
||||
.filter((column) => actual.columns[column.name] === undefined)
|
||||
.map((column) => addColumnOperation(table.name, column)),
|
||||
...Object.values(table.indexes)
|
||||
.filter((index) => actual.indexes[index.name] === undefined)
|
||||
.map(createIndexOperation),
|
||||
]
|
||||
})
|
||||
|
||||
expect(operations).toEqual(expected)
|
||||
}
|
||||
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<Omit<DatabaseMigration.ColumnAst, "name" | "type">> = {},
|
||||
): DatabaseMigration.ColumnAst {
|
||||
return {
|
||||
name,
|
||||
type: "text",
|
||||
notNull: options.notNull ?? false,
|
||||
primaryKey: options.primaryKey ?? false,
|
||||
...(options.default === undefined ? {} : { default: options.default }),
|
||||
}
|
||||
}
|
||||
|
||||
function makeIndex(name: string, table: string, columns: string[], unique = false): DatabaseMigration.IndexAst {
|
||||
return { name, table, columns, unique }
|
||||
}
|
||||
|
||||
function makeRandomTable(random: () => number, name: string) {
|
||||
const columns = Array.from({ length: 1 + Math.floor(random() * 8) }, (_, i) =>
|
||||
makeColumn(`column_${i}`, {
|
||||
primaryKey: i === 0,
|
||||
notNull: i === 0 || random() > 0.5,
|
||||
default: random() > 0.7 ? String(Math.floor(random() * 100)) : undefined,
|
||||
}),
|
||||
)
|
||||
const indexes = columns
|
||||
.filter((column) => !column.primaryKey && random() > 0.5)
|
||||
.map((column) => makeIndex(`${name}_${column.name}_idx`, name, [column.name], random() > 0.75))
|
||||
return makeTable(name, columns, indexes)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user