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
17 changes: 17 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/core/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer as sqliteLayer } from "#sqlite"
import { Context, Effect, Layer } from "effect"
import { sql } from "drizzle-orm"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
Expand All @@ -24,6 +25,9 @@ const layer = Layer.effect(
Effect.gen(function* () {
const db = yield* makeDatabase

const autoVacuum = yield* db.get<{ auto_vacuum: number }>(sql`PRAGMA auto_vacuum`)
if (autoVacuum?.auto_vacuum === 0) yield* db.run(sql`PRAGMA auto_vacuum = INCREMENTAL`)

yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/sql-sqlite-bun": "catalog:",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
Expand Down Expand Up @@ -85,6 +86,7 @@
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
Expand Down
278 changes: 277 additions & 1 deletion packages/opencode/src/cli/cmd/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,117 @@ import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import { effectCmd } from "../effect-cmd"
import { cmd, type WithDoubleDash } from "../cmd/cmd"
import { renameSync, unlinkSync, existsSync } from "node:fs"

type DbShape = Database.Interface["db"]

const TABLES = [
"session",
"message",
"part",
"event",
"event_sequence",
"session_message",
"session_input",
"session_context_epoch",
"todo",
"credential",
"permission",
"project",
"project_directory",
"workspace",
"account",
"account_state",
"control_account",
"session_share",
"data_migration",
] as const

export function dbStats(db: DbShape) {
return Effect.gen(function* () {
const pageCount = yield* db.get<{ page_count: number }>(sql`PRAGMA page_count`).pipe(Effect.orDie)
const pageSize = yield* db.get<{ page_size: number }>(sql`PRAGMA page_size`).pipe(Effect.orDie)
const freelist = yield* db.get<{ freelist_count: number }>(sql`PRAGMA freelist_count`).pipe(Effect.orDie)

const counts: Record<string, number> = {}
for (const table of TABLES) {
const exists = yield* db
.get<{ c: number }>(
sql`SELECT COUNT(*) as c FROM sqlite_master WHERE type='table' AND name=${table}`,
)
.pipe(Effect.orDie)
if (!exists?.c) {
counts[table] = 0
continue
}
const row = yield* db.get<{ c: number }>(sql`SELECT COUNT(*) as c FROM ${sql.identifier(table)}`).pipe(Effect.orDie)
counts[table] = row?.c ?? 0
}

const sizeBytes = (pageCount?.page_count ?? 0) * (pageSize?.page_size ?? 0)

return {
pageCount: pageCount?.page_count ?? 0,
pageSize: pageSize?.page_size ?? 0,
freelistCount: freelist?.freelist_count ?? 0,
sizeBytes,
sizeMB: Math.round((sizeBytes / 1024 / 1024) * 100) / 100,
tables: counts,
}
})
}

export function pruneOrphanedEvents(db: DbShape) {
return Effect.gen(function* () {
yield* db.run(sql`
DELETE FROM event
WHERE aggregate_id IN (
SELECT es.aggregate_id
FROM event_sequence es
LEFT JOIN session s ON s.id = es.aggregate_id
WHERE s.id IS NULL
)
`)

const eventsDeleted = (yield* db.get<{ c: number }>(sql`SELECT changes() as c`))?.c ?? 0

yield* db.run(sql`
DELETE FROM event_sequence
WHERE aggregate_id IN (
SELECT es.aggregate_id
FROM event_sequence es
LEFT JOIN session s ON s.id = es.aggregate_id
WHERE s.id IS NULL
)
`)

const sequencesDeleted = (yield* db.get<{ c: number }>(sql`SELECT changes() as c`))?.c ?? 0

return { eventsDeleted, sequencesDeleted }
}).pipe(Effect.orDie)
}

export function pruneOldSessions(db: DbShape, maxAgeDays: number) {
return Effect.gen(function* () {
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000

const candidates = yield* db
.all<{ id: string }>(sql`SELECT id FROM session WHERE time_updated < ${cutoff}`)
.pipe(Effect.orDie)

if (candidates.length === 0) return { sessionsDeleted: 0, eventsDeleted: 0, sequencesDeleted: 0 }

const ids = candidates.map((r) => r.id)
yield* db.run(sql`DELETE FROM session WHERE time_updated < ${cutoff}`)

const sessionsDeleted = (yield* db.get<{ c: number }>(sql`SELECT changes() as c`))?.c ?? 0

const pruneResult = yield* pruneOrphanedEvents(db)

return { sessionsDeleted, eventsDeleted: pruneResult.eventsDeleted, sequencesDeleted: pruneResult.sequencesDeleted }
}).pipe(Effect.orDie)
}

const QueryCommand = effectCmd({
command: "$0 [query]",
Expand Down Expand Up @@ -51,12 +162,177 @@ const PathCommand = effectCmd({
}),
})

const StatsCommand = effectCmd({
command: "stats",
describe: "show database statistics",
instance: false,
handler: Effect.fn("Cli.db.stats")(function* () {
const { db } = yield* Database.Service
const stats = yield* dbStats(db)

console.log(`Database: ${Database.path()}`)
console.log(`Size: ${stats.sizeMB} MB (${stats.pageCount} pages × ${stats.pageSize} bytes)`)
console.log(`Freelist: ${stats.freelistCount} pages (${Math.round((stats.freelistCount * stats.pageSize / 1024 / 1024) * 100) / 100} MB reclaimable)`)
console.log("")
console.log("Table row counts:")
for (const [table, count] of Object.entries(stats.tables)) {
if (count > 0) console.log(` ${table.padEnd(24)} ${count.toLocaleString()}`)
}

if (stats.sizeMB > 1000) {
console.log("")
console.log(`\x1b[33m\u26a0 Database exceeds 1 GB. Consider running 'opencode db prune' to reclaim space.\x1b[0m`)
}
}),
})

const PruneCommand = effectCmd({
command: "prune",
describe: "remove old or orphaned event data and reclaim space",
instance: false,
builder: (yargs: Argv) => {
return yargs
.option("dry-run", {
type: "boolean",
default: false,
describe: "Show what would be deleted without deleting",
})
.option("max-age", {
type: "number",
describe: "Delete sessions older than N days (also removes their events)",
})
},
handler: Effect.fn("Cli.db.prune")(function* (args: { "dry-run": boolean; "max-age"?: number }) {
const { db } = yield* Database.Service

if (args["max-age"] !== undefined) {
const cutoff = Date.now() - args["max-age"] * 24 * 60 * 60 * 1000
const candidates = yield* db
.all<{ id: string; time_updated: number }>(sql`SELECT id, time_updated FROM session WHERE time_updated < ${cutoff}`)
.pipe(Effect.orDie)

if (candidates.length === 0) {
console.log(`No sessions older than ${args["max-age"]} days found.`)
return
}

console.log(`Found ${candidates.length} session(s) older than ${args["max-age"]} days.`)

if (args["dry-run"]) {
console.log("\n--dry-run: no data was modified.")
return
}

const result = yield* pruneOldSessions(db, args["max-age"])
console.log(`Deleted ${result.sessionsDeleted} session(s), ${result.eventsDeleted.toLocaleString()} event rows, ${result.sequencesDeleted} sequence rows.`)
console.log("Run 'opencode db vacuum' to reclaim disk space.")
return
}

const orphanedEvents = yield* db
.all<{ aggregate_id: string; c: number }>(sql`
SELECT es.aggregate_id, COUNT(*) as c
FROM event_sequence es
LEFT JOIN session s ON s.id = es.aggregate_id
WHERE s.id IS NULL
GROUP BY es.aggregate_id
`)
.pipe(Effect.orDie)

const totalOrphaned = orphanedEvents.reduce((sum: number, r) => sum + r.c, 0)

if (orphanedEvents.length === 0) {
console.log("No orphaned events found.")
return
}

console.log(`Found ${totalOrphaned.toLocaleString()} orphaned event rows across ${orphanedEvents.length} session(s).`)

if (args["dry-run"]) {
console.log("\n--dry-run: no data was modified.")
return
}

const result = yield* pruneOrphanedEvents(db)
console.log(`Deleted ${result.eventsDeleted.toLocaleString()} event rows, ${result.sequencesDeleted} sequence rows.`)
console.log("Run 'opencode db vacuum' to reclaim disk space.")
}),
})

// VACUUM INTO + file swap is used instead of plain VACUUM to avoid WAL blow-up
// on large databases. Plain VACUUM in WAL mode can write a file comparable to the
// database size into the WAL before completing. VACUUM INTO writes a compacted
// copy to a separate file, then we swap it in after closing the connection.
// See https://github.com/anomalyco/opencode/issues/33356#issuecomment-5692387560
const VacuumCommand = cmd<{}, { "dry-run": boolean }>({
command: "vacuum",
describe: "reclaim free space from the database file",
async handler() {
const { AppRuntime } = await import("@/effect/app-runtime")
const dbPath = Database.path()
const vacuumPath = `${dbPath}.vacuum`

const before = await AppRuntime.runPromise(
Effect.gen(function* () {
const { db } = yield* Database.Service

const beforeStats = yield* db.get<{ page_count: number; freelist_count: number }>(sql`
SELECT page_count, (SELECT freelist_count FROM pragma_freelist_count) as freelist_count
`).pipe(Effect.orDie)

// Flush WAL into main database before vacuum
yield* db.run(sql`PRAGMA wal_checkpoint(TRUNCATE)`).pipe(Effect.orDie)

// VACUUM INTO creates a compacted copy without modifying the original.
// Unlike plain VACUUM, this does not cause WAL blow-up on large databases.
const escapedPath = vacuumPath.replace(/'/g, "''")
yield* db.run(sql.raw(`VACUUM INTO '${escapedPath}'`)).pipe(Effect.orDie)

return beforeStats
}),
)
// DB connection is now closed (runtime cleaned up)

const beforePages = before?.page_count ?? 0
const beforeFree = before?.freelist_count ?? 0
console.log(`Before: ${beforePages} pages, ${beforeFree} free`)
console.log("VACUUM INTO complete, swapping files...")

// Phase 2: atomic file swap (connection is closed)
if (!existsSync(vacuumPath)) {
console.error("VACUUM INTO did not produce an output file.")
process.exit(1)
}

renameSync(vacuumPath, dbPath)
// Clean up WAL and SHM files (will be recreated on next open)
try { unlinkSync(`${dbPath}-wal`) } catch {}
try { unlinkSync(`${dbPath}-shm`) } catch {}

// Phase 3: verify the swapped file
const after = await AppRuntime.runPromise(
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db.get<{ page_count: number; freelist_count: number }>(sql`
SELECT page_count, (SELECT freelist_count FROM pragma_freelist_count) as freelist_count
`).pipe(Effect.orDie)
}),
)

const afterPages = after?.page_count ?? 0
const pageSize = 4096
const reclaimedMB = Math.round(((beforePages - afterPages) * pageSize / 1024 / 1024) * 100) / 100
console.log(`After: ${afterPages} pages, ${after?.freelist_count ?? 0} free`)
console.log(`Reclaimed: ${reclaimedMB} MB`)
},
})

export const DbCommand = effectCmd({
command: "db",
describe: "database tools",
instance: false,
builder: (yargs: Argv) => {
return yargs.command(QueryCommand).command(PathCommand).demandCommand()
return yargs.command(QueryCommand).command(PathCommand).command(StatsCommand).command(PruneCommand).command(VacuumCommand).demandCommand()
},
handler: Effect.fn("Cli.db")(function* () {}),
})
Loading
Loading