feat(core): add sqlite schema sync
This commit is contained in:
parent
3cf955e9ad
commit
6bd47e1bce
23 changed files with 712 additions and 42 deletions
45
packages/core/src/database/database.ts
Normal file
45
packages/core/src/database/database.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export * as Database from "./database"
|
||||
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Global } from "../global"
|
||||
import { Flag } from "../flag/flag"
|
||||
import path from "path"
|
||||
|
||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||
|
||||
export class Service extends Context.Service<Service, DatabaseShape>()("@opencode/v2/storage/Database") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
return db
|
||||
}),
|
||||
)
|
||||
|
||||
export function layerFromPath(filename: string) {
|
||||
return layer.pipe(Layer.provide(SqliteClient.layer({ filename })))
|
||||
}
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return layerFromPath(
|
||||
!Flag.OPENCODE_DB
|
||||
? path.join(Global.Path.data, "opencode.db")
|
||||
: Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)
|
||||
? Flag.OPENCODE_DB
|
||||
: path.join(Global.Path.data, Flag.OPENCODE_DB),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(Global.defaultLayer))
|
||||
248
packages/core/src/database/migration.ts
Normal file
248
packages/core/src/database/migration.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
export * as DatabaseMigration from "./migration"
|
||||
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { Effect } from "effect"
|
||||
import { getTableName, sql, type SQL, type Table } from "drizzle-orm"
|
||||
import { getTableConfig, type AnySQLiteTable, type Index, type SQLiteColumn } from "drizzle-orm/sqlite-core"
|
||||
|
||||
export type SchemaAst = {
|
||||
tables: Record<string, TableAst>
|
||||
}
|
||||
|
||||
export type TableAst = {
|
||||
name: string
|
||||
columns: Record<string, ColumnAst>
|
||||
indexes: Record<string, IndexAst>
|
||||
}
|
||||
|
||||
export type ColumnAst = {
|
||||
name: string
|
||||
type: string
|
||||
notNull: boolean
|
||||
primaryKey: boolean
|
||||
default?: string
|
||||
}
|
||||
|
||||
export type IndexAst = {
|
||||
name: string
|
||||
table: string
|
||||
columns: IndexColumnAst[]
|
||||
unique: boolean
|
||||
where?: string
|
||||
}
|
||||
|
||||
export type IndexColumnAst = { type: "column"; name: string } | { type: "expression"; sql: string }
|
||||
|
||||
export type Operation =
|
||||
| { type: "create_table"; table: TableAst }
|
||||
| { type: "add_column"; table: string; column: ColumnAst }
|
||||
| { type: "create_index"; index: IndexAst }
|
||||
|
||||
export function diff(db: EffectDrizzleSqlite.EffectSQLiteDatabase, tables: Table[]) {
|
||||
return read(db).pipe(Effect.map((actual) => diffSchema(actual, fromTables(tables))))
|
||||
}
|
||||
|
||||
export function apply(db: EffectDrizzleSqlite.EffectSQLiteDatabase, operations: Operation[]) {
|
||||
return Effect.forEach(operations, (operation) => db.run(toSql(operation))).pipe(Effect.asVoid)
|
||||
}
|
||||
|
||||
function fromTables(tables: Table[]): SchemaAst {
|
||||
return {
|
||||
tables: Object.fromEntries(tables.map((table) => {
|
||||
const config = getTableConfig(table as AnySQLiteTable)
|
||||
const name = getTableName(table)
|
||||
return [name, tableFromConfig(name, config.columns, config.indexes)]
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function diffSchema(actual: SchemaAst, desired: SchemaAst): Operation[] {
|
||||
return Object.values(desired.tables).flatMap<Operation>((table) => {
|
||||
const current = actual.tables[table.name]
|
||||
if (!current) {
|
||||
return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)]
|
||||
}
|
||||
return [
|
||||
...Object.values(table.columns)
|
||||
.filter((column) => current.columns[column.name] === undefined)
|
||||
.map((column) => addColumnOperation(table.name, column)),
|
||||
...Object.values(table.indexes)
|
||||
.filter((index) => current.indexes[index.name] === undefined)
|
||||
.map(createIndexOperation),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function createTableOperation(table: TableAst): Operation {
|
||||
return { type: "create_table", table }
|
||||
}
|
||||
|
||||
function addColumnOperation(table: string, column: ColumnAst): Operation {
|
||||
return { type: "add_column", table, column }
|
||||
}
|
||||
|
||||
function createIndexOperation(index: IndexAst): Operation {
|
||||
return { type: "create_index", index }
|
||||
}
|
||||
|
||||
function toSql(operation: Operation) {
|
||||
if (operation.type === "create_table") {
|
||||
return `CREATE TABLE ${quoteIdentifier(operation.table.name)} (${Object.values(operation.table.columns)
|
||||
.map((column) => columnSql(column, true))
|
||||
.join(", ")})`
|
||||
}
|
||||
if (operation.type === "add_column") {
|
||||
return `ALTER TABLE ${quoteIdentifier(operation.table)} ADD COLUMN ${columnSql(operation.column, false)}`
|
||||
}
|
||||
return [
|
||||
"CREATE",
|
||||
operation.index.unique ? "UNIQUE" : undefined,
|
||||
"INDEX",
|
||||
quoteIdentifier(operation.index.name),
|
||||
"ON",
|
||||
quoteIdentifier(operation.index.table),
|
||||
`(${operation.index.columns.map(indexColumnSql).join(", ")})`,
|
||||
operation.index.where === undefined ? undefined : `WHERE ${operation.index.where}`,
|
||||
]
|
||||
.filter((part) => part !== undefined)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function read(db: EffectDrizzleSqlite.EffectSQLiteDatabase) {
|
||||
return Effect.gen(function* () {
|
||||
const rows = yield* db.all<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
|
||||
const tables = yield* Effect.forEach(rows, (row) => readTable(db, row.name))
|
||||
return { tables: Object.fromEntries(tables.map((table) => [table.name, table])) }
|
||||
})
|
||||
}
|
||||
|
||||
function readTable(db: EffectDrizzleSqlite.EffectSQLiteDatabase, name: string) {
|
||||
return Effect.gen(function* () {
|
||||
const columns = yield* db.all<{
|
||||
name: string
|
||||
type: string
|
||||
notnull: number
|
||||
pk: number
|
||||
dflt_value: string | null
|
||||
}>(`PRAGMA table_info(${quoteIdentifier(name)})`)
|
||||
const indexes = yield* db.all<{ name: string; unique: number }>(`PRAGMA index_list(${quoteIdentifier(name)})`)
|
||||
const indexEntries = yield* Effect.forEach(indexes, (index) =>
|
||||
Effect.gen(function* () {
|
||||
const statement = yield* db.get<{ sql: string | null }>(sql`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ${index.name}`)
|
||||
if (statement?.sql === null || statement?.sql === undefined) return undefined
|
||||
const columns = yield* db.all<{ seqno: number; name: string | null }>(`PRAGMA index_info(${quoteIdentifier(index.name)})`)
|
||||
return [
|
||||
index.name,
|
||||
{
|
||||
name: index.name,
|
||||
table: name,
|
||||
columns: columns.map((column) =>
|
||||
column.name === null
|
||||
? ({ type: "expression", sql: "" } as const)
|
||||
: ({ type: "column", name: column.name } as const),
|
||||
),
|
||||
unique: index.unique === 1,
|
||||
},
|
||||
] as const
|
||||
}),
|
||||
)
|
||||
return {
|
||||
name,
|
||||
columns: Object.fromEntries(columns.map((column) => [
|
||||
column.name,
|
||||
{
|
||||
name: column.name,
|
||||
type: column.type,
|
||||
notNull: column.notnull === 1,
|
||||
primaryKey: column.pk > 0,
|
||||
...(column.dflt_value === null ? {} : { default: column.dflt_value }),
|
||||
},
|
||||
])),
|
||||
indexes: Object.fromEntries(indexEntries.filter((entry) => entry !== undefined)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function tableFromConfig(name: string, columns: SQLiteColumn[], indexes: Index[]): TableAst {
|
||||
return {
|
||||
name,
|
||||
columns: Object.fromEntries(columns.map((column) => [column.name, columnFromConfig(column)])),
|
||||
indexes: Object.fromEntries(indexes.map((index) => [index.config.name, indexFromConfig(index)])),
|
||||
}
|
||||
}
|
||||
|
||||
function columnFromConfig(column: SQLiteColumn): ColumnAst {
|
||||
return {
|
||||
name: column.name,
|
||||
type: column.getSQLType(),
|
||||
notNull: column.notNull,
|
||||
primaryKey: column.primary,
|
||||
...defaultFromColumn(column),
|
||||
}
|
||||
}
|
||||
|
||||
function defaultFromColumn(column: SQLiteColumn) {
|
||||
if (column.default !== undefined) return { default: literal(column.default) }
|
||||
if (column.defaultFn !== undefined) return { default: literal(column.defaultFn()) }
|
||||
return {}
|
||||
}
|
||||
|
||||
function indexFromConfig(index: Index): IndexAst {
|
||||
return {
|
||||
name: index.config.name,
|
||||
table: getTableName(index.config.table),
|
||||
columns: index.config.columns.map(indexColumnName),
|
||||
unique: index.config.unique,
|
||||
...(index.config.where === undefined ? {} : { where: compileSql(index.config.where) }),
|
||||
}
|
||||
}
|
||||
|
||||
function indexColumnName(column: SQLiteColumn | SQL) {
|
||||
if ("name" in column) return { type: "column", name: column.name } as const
|
||||
return { type: "expression", sql: compileSql(column) } as const
|
||||
}
|
||||
|
||||
function compileSql(value: SQL) {
|
||||
return value.getSQL().toQuery(new SQLiteCompiler()).sql.replace(/"(?:""|[^"])*"\./g, "")
|
||||
}
|
||||
|
||||
function indexColumnSql(column: IndexColumnAst) {
|
||||
if (column.type === "column") return quoteIdentifier(column.name)
|
||||
return column.sql
|
||||
}
|
||||
|
||||
function columnSql(column: ColumnAst, includePrimaryKey: boolean) {
|
||||
return [
|
||||
quoteIdentifier(column.name),
|
||||
column.type,
|
||||
includePrimaryKey && column.primaryKey ? "PRIMARY KEY" : undefined,
|
||||
column.notNull ? "NOT NULL" : undefined,
|
||||
column.default === undefined ? undefined : `DEFAULT ${column.default}`,
|
||||
]
|
||||
.filter((part) => part !== undefined)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
class SQLiteCompiler {
|
||||
inlineParams = true
|
||||
escapeName = (name: string) => {
|
||||
return quoteIdentifier(name)
|
||||
}
|
||||
escapeParam = () => {
|
||||
return "?"
|
||||
}
|
||||
escapeString = (value: string) => {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
}
|
||||
}
|
||||
|
||||
function literal(value: unknown) {
|
||||
if (typeof value === "number") return String(value)
|
||||
if (typeof value === "boolean") return value ? "1" : "0"
|
||||
if (value === null) return "NULL"
|
||||
return `'${String(value).replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
function quoteIdentifier(value: string) {
|
||||
return `"${value.replaceAll('"', '""')}"`
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { Schema } from "effect"
|
||||
import { EventV2 } from "./event"
|
||||
import { ModelV2 } from "./model"
|
||||
import { NonNegativeInt } from "./schema"
|
||||
import { Session } from "./session"
|
||||
import { FileAttachment, Prompt } from "./session-prompt"
|
||||
import { ToolOutput } from "./tool-output"
|
||||
import { V2Schema } from "./v2-schema"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { ToolOutput } from "../tool-output"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
import { Session } from "./index"
|
||||
import { FileAttachment, Prompt } from "./prompt"
|
||||
|
||||
export { FileAttachment }
|
||||
|
||||
|
|
@ -399,4 +399,4 @@ export const All = Schema.Union(
|
|||
export type Event = typeof All.Type
|
||||
export type Type = Event["type"]
|
||||
|
||||
export * as SessionEvent from "./session-event"
|
||||
export * as SessionEvent from "./event"
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
export * as Session from "./session"
|
||||
export * as Session from "."
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { withStatics } from "../schema"
|
||||
import { Identifier } from "../util/identifier"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { produce, type WritableDraft } from "immer"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { SessionMessage } from "./session-message"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
export type MemoryState = {
|
||||
messages: SessionMessage.Message[]
|
||||
|
|
@ -414,4 +414,4 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
|
|||
return adapter.finish()
|
||||
}
|
||||
|
||||
export * as SessionMessageUpdater from "./session-message-updater"
|
||||
export * as SessionMessageUpdater from "./message-updater"
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { Schema } from "effect"
|
||||
import { Prompt } from "./session-prompt"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { EventV2 } from "./event"
|
||||
import { ToolOutput } from "./tool-output"
|
||||
import { V2Schema } from "./v2-schema"
|
||||
import { ModelV2 } from "./model"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ToolOutput } from "../tool-output"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
import { SessionEvent } from "./event"
|
||||
import { Prompt } from "./prompt"
|
||||
|
||||
export const ID = EventV2.ID
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
|
@ -170,4 +170,4 @@ export type Message = Schema.Schema.Type<typeof Message>
|
|||
|
||||
export type Type = Message["type"]
|
||||
|
||||
export * as SessionMessage from "./session-message"
|
||||
export * as SessionMessage from "./message"
|
||||
49
packages/core/src/session/sql.ts
Normal file
49
packages/core/src/session/sql.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { Session } from "."
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
id: text().$type<Session.ID>().primaryKey(),
|
||||
project_id: text().notNull(),
|
||||
workspace_id: text(),
|
||||
parent_id: text().$type<Session.ID>(),
|
||||
slug: text().notNull(),
|
||||
directory: text().notNull(),
|
||||
path: text(),
|
||||
title: text().notNull(),
|
||||
version: text().notNull(),
|
||||
share_url: text(),
|
||||
summary_additions: integer(),
|
||||
summary_deletions: integer(),
|
||||
summary_files: integer(),
|
||||
summary_diffs: text({ mode: "json" }),
|
||||
cost: real().notNull().default(0),
|
||||
tokens_input: integer().notNull().default(0),
|
||||
tokens_output: integer().notNull().default(0),
|
||||
tokens_reasoning: integer().notNull().default(0),
|
||||
tokens_cache_read: integer().notNull().default(0),
|
||||
tokens_cache_write: integer().notNull().default(0),
|
||||
revert: text({ mode: "json" }),
|
||||
permission: text({ mode: "json" }),
|
||||
agent: text(),
|
||||
model: text({ mode: "json" }).$type<{
|
||||
id: string
|
||||
providerID: string
|
||||
variant?: string
|
||||
}>(),
|
||||
time_created: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
time_updated: integer()
|
||||
.notNull()
|
||||
.$onUpdate(() => Date.now()),
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
},
|
||||
(table) => [
|
||||
index("session_project_idx").on(table.project_id),
|
||||
index("session_workspace_idx").on(table.workspace_id),
|
||||
index("session_parent_idx").on(table.parent_id),
|
||||
],
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue