Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 27 additions & 21 deletions packages/opencode/src/cli/cmd/db.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "@/storage/db"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { DatabaseSync } from "@dolthub/doltlite"
import { init as initDoltlite } from "@/storage/db.doltlite.bun"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { JsonMigration } from "@/storage/json-migration"
import { EOL } from "os"
import { errorMessage } from "../../util/error"

function printRows(rows: Record<string, unknown>[], format: string) {
if (format === "json") {
console.log(JSON.stringify(rows, null, 2))
return
}
if (rows.length === 0) return
const keys = Object.keys(rows[0])
console.log(keys.join("\t"))
for (const row of rows) {
console.log(keys.map((k) => row[k]).join("\t"))
}
}

const QueryCommand = cmd({
command: "$0 [query]",
describe: "open an interactive sqlite3 shell or run a query",
describe: "open an interactive doltlite shell or run a query",
builder: (yargs: Argv) => {
return yargs
.positional("query", {
Expand All @@ -28,28 +41,21 @@ const QueryCommand = cmd({
handler: async (args: { query?: string; format: string }) => {
const query = args.query as string | undefined
if (query) {
const db = new BunDatabase(Database.Path, { readonly: true })
const db = new DatabaseSync(Database.Path, { readOnly: true })
try {
const result = db.query(query).all() as Record<string, unknown>[]
if (args.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (result.length > 0) {
const keys = Object.keys(result[0])
console.log(keys.join("\t"))
for (const row of result) {
console.log(keys.map((k) => row[k]).join("\t"))
}
}
const rows = db.prepare(query).all() as Record<string, unknown>[]
printRows(rows, args.format)
} catch (err) {
UI.error(errorMessage(err))
process.exit(1)
} finally {
db.close()
}
db.close()
return
}
const child = spawn("sqlite3", [Database.Path], {
stdio: "inherit",
})
// The `doltlite` CLI is a sqlite3-style shell that ships with doltlite.
// Same convention as before — assume it's on $PATH.
const child = spawn("doltlite", [Database.Path], { stdio: "inherit" })
await new Promise((resolve) => child.on("close", resolve))
},
})
Expand All @@ -66,7 +72,7 @@ const MigrateCommand = cmd({
command: "migrate",
describe: "migrate JSON data to SQLite (merges with existing data)",
handler: async () => {
const sqlite = new BunDatabase(Database.Path)
const db = initDoltlite(Database.Path)
const tty = process.stderr.isTTY
const width = 36
const orange = "\x1b[38;5;214m"
Expand All @@ -75,7 +81,7 @@ const MigrateCommand = cmd({
let last = -1
if (tty) process.stderr.write("\x1b[?25l")
try {
const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
const stats = await JsonMigration.run(db, {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last) return
Expand Down Expand Up @@ -105,7 +111,7 @@ const MigrateCommand = cmd({
UI.error(`Migration failed: ${errorMessage(err)}`)
process.exit(1)
} finally {
sqlite.close()
;(db as any).$client.close()
}
},
})
Expand Down
95 changes: 16 additions & 79 deletions packages/opencode/src/storage/db.doltlite.bun.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,31 @@
// Used in bun environments. drizzle-orm/bun-sqlite passes statement params as
// spread args (stmt.all(...params)) while doltlite's node:sqlite-style API
// expects a single array (stmt.all(params)). DoltliteClientAdapter bridges the
// difference so we can pass it as the `client` option to drizzle-orm/bun-sqlite.
// Used in bun environments. drizzle-orm/bun-sqlite expects two methods that
// doltlite-node's node:sqlite-style API doesn't have:
// - client.transaction(fn).immediate() — bun:sqlite's transaction shape
// - stmt.values(...params) — bun:sqlite's rows-as-arrays accessor
// DoltliteClientAdapter polyfills both. It also bridges the spread-vs-single-
// array param convention (drizzle spreads, doltlite-node expects an array).
//
// NOTE: this adapter does NOT auto-create dolt commits per write. Auto-commit
// per transaction is expensive (each one stages all tables and writes a new
// root) and surprised opencode's read-after-write semantics. Dolt history is
// captured explicitly higher in the stack — see snapshot points in db.ts and
// service-shutdown paths.
import { DatabaseSync, StatementSync } from "@dolthub/doltlite"
import { drizzle } from "drizzle-orm/bun-sqlite"

// Tables whose writes should each produce a Dolt commit.
const TRACKED = new Set(["session", "message", "part", "todo", "session_message", "permission"])

interface WriteOp {
table: string
op: "insert" | "update" | "delete"
}

// Parse the table name and DML type from a prepared statement's SQL text.
// Returns null for SELECTs, DDL, or writes to untracked tables.
function parseWriteOp(sql: string): WriteOp | null {
const s = sql.trimStart()
let m: RegExpMatchArray | null

m = s.match(/^insert\s+(?:or\s+\w+\s+)?into\s+"?(\w+)"?/i)
if (m && TRACKED.has(m[1])) return { table: m[1], op: "insert" }

m = s.match(/^update\s+"?(\w+)"?/i)
if (m && TRACKED.has(m[1])) return { table: m[1], op: "update" }

m = s.match(/^delete\s+from\s+"?(\w+)"?/i)
if (m && TRACKED.has(m[1])) return { table: m[1], op: "delete" }

return null
}

// Build a ≤120-char commit message from a list of write ops.
function buildMessage(ops: WriteOp[]): string {
const counts = new Map<string, number>()
for (const { op, table } of ops) {
const key = `${op} ${table}`
counts.set(key, (counts.get(key) ?? 0) + 1)
}
const parts = Array.from(counts.entries()).map(([key, n]) => (n === 1 ? key : `${key} ×${n}`))
const msg = parts.join(", ")
return msg.length <= 120 ? msg : msg.slice(0, 117) + "..."
}

class DoltliteStatement {
private readonly writeOp: WriteOp | null

constructor(
private readonly stmt: StatementSync,
sql: string,
private readonly adapter: DoltliteClientAdapter,
) {
this.writeOp = parseWriteOp(sql)
}
constructor(private readonly stmt: StatementSync) {}

run(...args: unknown[]) {
const result = this.stmt.run(args.length ? args : undefined)
if (this.writeOp && result.changes > 0) {
this.adapter.onWrite(this.writeOp)
}
return result
return this.stmt.run(args.length ? args : undefined)
}
all(...args: unknown[]) {
return this.stmt.all(args.length ? args : undefined)
}
get(...args: unknown[]) {
return this.stmt.get(args.length ? args : undefined)
}
// drizzle uses values() to get rows as ordered arrays for column-mapped results.
// drizzle-bun-sqlite calls stmt.values() to get rows as ordered arrays.
values(...args: unknown[]): unknown[][] {
const rows = this.stmt.all(args.length ? args : undefined) as Record<string, unknown>[]
if (!rows.length) return []
Expand All @@ -77,13 +35,10 @@ class DoltliteStatement {
}

class DoltliteClientAdapter {
private inTx = false
private txOps: WriteOp[] = []

constructor(private readonly db: DatabaseSync) {}

prepare(sql: string) {
return new DoltliteStatement(this.db.prepare(sql), sql, this)
return new DoltliteStatement(this.db.prepare(sql))
}
exec(query: string) {
this.db.exec(query)
Expand All @@ -92,34 +47,16 @@ class DoltliteClientAdapter {
this.db.close()
}

// Called by DoltliteStatement.run() whenever a tracked write lands.
onWrite(op: WriteOp) {
if (this.inTx) {
this.txOps.push(op)
} else {
this.db.doltCommit(buildMessage([op]))
}
}

// bun:sqlite's Database.transaction() returns an object with .deferred(),
// .immediate(), .exclusive() — drizzle calls one of them to run the txn.
transaction(fn: () => void) {
const db = this.db
const adapter = this
const run = (behavior: string) => () => {
db.exec(`BEGIN ${behavior}`)
adapter.inTx = true
adapter.txOps = []
try {
fn()
db.exec("COMMIT")
const ops = adapter.txOps.slice()
adapter.inTx = false
adapter.txOps = []
if (ops.length > 0) db.doltCommit(buildMessage(ops))
} catch (err) {
adapter.inTx = false
adapter.txOps = []
db.exec("ROLLBACK")
throw err
}
Expand Down
146 changes: 146 additions & 0 deletions packages/opencode/test/storage/db-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Adapter-level read-after-write tests. These cover the opencode-on-doltlite
// regression where a session row inserted inside BEGIN IMMEDIATE / COMMIT
// became invisible to subsequent SELECTs — the original adapter triggered
// dolt_commit per write, which interacted badly with read-after-write
// semantics. This file pins down the read-after-write contract so any future
// adapter changes have to keep passing it.
import { describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs"
import os from "os"
import { eq } from "drizzle-orm"
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { init } from "@/storage/db.doltlite.bun"

function freshDb() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-adapter-test-"))
const dbPath = path.join(dir, "adapter.ddb")
const db = init(dbPath)
return { db, dir }
}

// Minimal opencode-shaped schema: project + session with FK.
const project = sqliteTable("project", {
id: text("id").primaryKey(),
worktree: text("worktree").notNull(),
timeCreated: integer("time_created").notNull(),
timeUpdated: integer("time_updated").notNull(),
sandboxes: text("sandboxes").notNull(),
})

const session = sqliteTable("session", {
id: text("id").primaryKey(),
projectId: text("project_id").notNull().references(() => project.id, { onDelete: "cascade" }),
slug: text("slug").notNull(),
directory: text("directory").notNull(),
title: text("title").notNull(),
version: text("version").notNull(),
agent: text("agent"),
model: text("model"),
timeCreated: integer("time_created").notNull(),
timeUpdated: integer("time_updated").notNull(),
})

function makeSchema(db: ReturnType<typeof init>) {
db.$client.exec("PRAGMA foreign_keys = ON")
db.$client.exec(`
CREATE TABLE project (
id text PRIMARY KEY, worktree text NOT NULL,
time_created integer NOT NULL, time_updated integer NOT NULL,
sandboxes text NOT NULL
);
CREATE TABLE session (
id text PRIMARY KEY, project_id text NOT NULL,
slug text NOT NULL, directory text NOT NULL,
title text NOT NULL, version text NOT NULL,
agent text, model text,
time_created integer NOT NULL, time_updated integer NOT NULL,
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
);
`)
db.insert(project).values({
id: "global", worktree: "/", timeCreated: 1, timeUpdated: 1, sandboxes: "[]",
}).run()
}

describe("DoltliteClientAdapter — read-after-write", () => {
test("autocommit insert is visible to immediate SELECT", () => {
const { db } = freshDb()
makeSchema(db)
db.insert(session).values({
id: "ses_auto", projectId: "global", slug: "s",
directory: "/", title: "t", version: "v",
agent: "build", model: "m", timeCreated: 1, timeUpdated: 1,
}).run()
const rows = db.select().from(session).where(eq(session.id, "ses_auto")).all()
expect(rows.length).toBe(1)
})

test("BEGIN IMMEDIATE / INSERT / COMMIT — row visible to autocommit SELECT", () => {
const { db } = freshDb()
makeSchema(db)
db.transaction((tx) => {
tx.insert(session).values({
id: "ses_imm", projectId: "global", slug: "s",
directory: "/", title: "t", version: "v",
agent: "build", model: "m", timeCreated: 1, timeUpdated: 1,
}).run()
}, { behavior: "immediate" })
// Repeat the SELECT four times to mirror opencode's retry-loop in the wild.
for (let i = 0; i < 4; i++) {
const rows = db.select().from(session).where(eq(session.id, "ses_imm")).all()
expect(rows.length).toBe(1)
}
})

test("BEGIN DEFERRED / INSERT / COMMIT — row visible", () => {
const { db } = freshDb()
makeSchema(db)
db.transaction((tx) => {
tx.insert(session).values({
id: "ses_def", projectId: "global", slug: "s",
directory: "/", title: "t", version: "v",
agent: "build", model: "m", timeCreated: 1, timeUpdated: 1,
}).run()
})
const rows = db.select().from(session).where(eq(session.id, "ses_def")).all()
expect(rows.length).toBe(1)
})

test("ROLLBACK throws and discards the insert", () => {
const { db } = freshDb()
makeSchema(db)
expect(() =>
db.transaction((tx) => {
tx.insert(session).values({
id: "ses_rb", projectId: "global", slug: "s",
directory: "/", title: "t", version: "v",
agent: "build", model: "m", timeCreated: 1, timeUpdated: 1,
}).run()
throw new Error("trigger rollback")
}, { behavior: "immediate" })
).toThrow("trigger rollback")
const rows = db.select().from(session).where(eq(session.id, "ses_rb")).all()
expect(rows.length).toBe(0)
})

test("multiple sequential transactions all visible", () => {
const { db } = freshDb()
makeSchema(db)
for (let i = 0; i < 5; i++) {
db.transaction((tx) => {
tx.insert(session).values({
id: `ses_${i}`, projectId: "global", slug: "s",
directory: "/", title: "t", version: "v",
agent: "build", model: "m", timeCreated: i, timeUpdated: i,
}).run()
}, { behavior: "immediate" })
}
const all = db.select().from(session).all()
expect(all.length).toBe(5)
for (let i = 0; i < 5; i++) {
const row = db.select().from(session).where(eq(session.id, `ses_${i}`)).get()
expect(row?.id).toBe(`ses_${i}`)
}
})
})