Skip to content
Merged
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
27 changes: 27 additions & 0 deletions docs/adr/0004-unified-session-database.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Unified session database: remove channel-based DB path splitting

Status: proposed (2026-08-12)

Tracking: harmoniqs/opencode#188 · Glossary: `CONTEXT.md` (Installation Channel)

All opencode installations on a single machine write to one database file (`opencode.db`) regardless of their build channel. The channel-based path splitting (`opencode-dev.db`, `opencode-local.db`, branch-named DBs) is removed. On first startup after the change, a one-time consolidation migrates rows from any legacy channel-named DB files into the canonical `opencode.db`, deduplicating by primary key, then renames the sources to `*.db.merged` as backups.

**Why:** the channel-DB split was introduced defensively — to prevent a dev build from corrupting a release user's session history. In practice, sessions are append-only, schema-migrated identically across all channels, and the split only fragments session history for users who switch between dev and release builds on the same machine (the common case for contributors). The existing `consolidateLocalDb` function already acknowledges this by merging per-branch DBs into one — this change extends the same logic to its natural conclusion.

**Mechanism:**

1. `path()` in `database.ts` reduces to: if `OPENCODE_DB` is set, use it; otherwise return `join(Global.Path.data, "opencode.db")` with a one-time consolidation pass.
2. The consolidation function scans `Global.Path.data` for any `opencode-*.db` files (matching `opencode-dev.db`, `opencode-local.db`, `opencode-beta.db`, and any branch-named variants). For each found file, it ATTACHes it via SQLite, iterates all user tables, and runs `INSERT OR IGNORE INTO main.<table> SELECT * FROM source.<table>` (primary-key dedup). Foreign key checks are disabled during the merge. Successfully merged sources are renamed to `*.db.merged`; their WAL/SHM sidecars are deleted.
3. If `opencode.db` does not yet exist but channel DBs do, the largest channel DB is renamed to become `opencode.db` (the base), then remaining channel DBs are merged into it. This avoids a full copy of the largest file.
4. The `OPENCODE_DB` environment variable override is retained for CI, testing, and `:memory:` use.
5. `OPENCODE_DISABLE_CHANNEL_DB`, `STABLE_CHANNELS`, and all channel-branching logic are deleted.

**Standalone merge script:** a `merge-opencode-dbs.sh` bash script (distributed separately, not in the repo) provides the same consolidation for users who haven't upgraded yet. It requires only `sqlite3` on PATH, auto-detects the data directory, and performs the same ATTACH + INSERT OR IGNORE + backup flow.

**Conditions of merge:** the consolidation must be idempotent (re-running after a partial failure picks up where it left off — already-merged files have been renamed, un-merged ones are retried). The merge must not block startup for more than ~5 seconds on a 500 MB combined DB size. WAL mode must be checkpointed on source DBs before ATTACH (to avoid attaching a DB mid-transaction).

**Flip condition:** if opencode ever needs true multi-tenant isolation (e.g. separate DB per workspace for portability), revisit with a workspace-scoped model rather than channel-scoped.

**Accepted costs:** users who deliberately kept channel DBs separate (for A/B testing session behavior across versions) lose that separation. The `*.db.merged` backups consume disk until manually deleted.

**Considered:** env-var-only patch (`OPENCODE_DISABLE_CHANNEL_DB=1` in build config — runner-up: zero code change, but doesn't merge existing data and doesn't fix upstream); explicit CLI command (`opencode db merge` — rejected: the right behavior should be automatic, most users won't discover or run a manual command and will just lose history); keep channel split but add cross-DB search (rejected: complexity for a problem that shouldn't exist).
202 changes: 155 additions & 47 deletions packages/core/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { existsSync, readdirSync, renameSync, statSync, unlinkSync } from "fs"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { makeGlobalNode } from "../effect/app-node"
import { Database as BunDatabase } from "bun:sqlite"

const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
Expand Down Expand Up @@ -42,64 +42,182 @@ export function layerFromPath(filename: string) {
}

/**
* Known stable channels that get their own dedicated DB file.
* Everything else (dev branches like "tab-drag-reorder", "local/amicode", etc.)
* collapses to "opencode-local.db" so branch switches don't strand sessions.
* All tables to merge via INSERT OR IGNORE. Order matters: parents before
* children to satisfy foreign key relationships on re-enable.
*/
const STABLE_CHANNELS = new Set(["dev", "latest", "beta", "prod"])
const MERGE_TABLES = [
"migration",
"data_migration",
"project",
"project_directory",
"workspace",
"account",
"account_state",
"control_account",
"credential",
"event_sequence",
"event",
"permission",
"session",
"session_message",
"session_input",
"session_context_epoch",
"session_share",
"message",
"part",
"todo",
]

/**
* On first access, if `opencode-local.db` doesn't exist or is smaller than an
* available branch-named DB, rename the largest branch DB to become the local
* DB. This makes the migration from per-branch DBs fully transparent — no
* manual intervention needed when users pull this change.
* One-time consolidation of legacy channel-named DBs into the unified
* `opencode.db`. Runs synchronously at startup before the Effect DB layer
* initializes.
*
* Strategy:
* 1. Find all `opencode-*.db` files (skip already-merged `*.db.merged`).
* 2. If `opencode.db` doesn't exist, rename the largest source to become it
* (avoids a full copy of the biggest file).
* 3. For each remaining source: checkpoint WAL, ATTACH to target, INSERT OR
* IGNORE per table, DETACH, rename source to `*.db.merged`.
* 4. Clean up WAL/SHM sidecars of merged sources.
*
* Idempotent: partially-merged sources remain un-renamed and will be retried
* on next startup. Already `.db.merged` files are ignored.
*/
function consolidateLocalDb(targetPath: string) {
function consolidateChannelDbs(targetPath: string) {
const dir = Global.Path.data
let candidates: { path: string; size: number; mtime: number }[]

// Collect candidate channel DBs
let candidates: { path: string; name: string; size: number }[]
try {
const stableFiles = new Set([...STABLE_CHANNELS].map((c) => `opencode-${c}.db`))
stableFiles.add("opencode.db")
stableFiles.add("opencode-local.db")
candidates = readdirSync(dir)
.filter((f) => f.startsWith("opencode-") && f.endsWith(".db") && !stableFiles.has(f))
.filter((f) => f.startsWith("opencode-") && f.endsWith(".db") && !f.endsWith(".db.merged"))
.map((f) => {
const full = join(dir, f)
try {
const st = statSync(full)
return { path: full, size: st.size, mtime: st.mtimeMs }
return { path: full, name: f, size: st.size }
} catch {
return null
}
})
.filter((x): x is { path: string; size: number; mtime: number } => x !== null)
.filter((x): x is { path: string; name: string; size: number } => x !== null)
} catch {
return
}

if (candidates.length === 0) return
// Pick the largest branch DB (most session data)

// Sort largest first — the biggest becomes the base if target doesn't exist
candidates.sort((a, b) => b.size - a.size)
const best = candidates[0]
// Only consolidate if target doesn't exist or is smaller (i.e. has less data)
const targetSize = existsSync(targetPath) ? (statSync(targetPath).size ?? 0) : 0
if (targetSize >= best.size) return
try {
// Remove the smaller target if it exists (schema-only or empty)
if (existsSync(targetPath)) {
unlinkSync(targetPath)

// If target doesn't exist, promote the largest candidate by rename
if (!existsSync(targetPath)) {
const largest = candidates.shift()!
try {
renameSync(largest.path, targetPath)
// Move sidecars too
for (const ext of ["-wal", "-shm"]) {
const sidecar = targetPath + ext
if (existsSync(sidecar)) unlinkSync(sidecar)
const sidecar = largest.path + ext
if (existsSync(sidecar)) renameSync(sidecar, targetPath + ext)
}
} catch {
// If rename fails, put it back in the list to be merged normally
candidates.unshift(largest)
}
renameSync(best.path, targetPath)
// Also rename WAL/SHM sidecars if they exist
for (const ext of ["-wal", "-shm"]) {
const sidecar = best.path + ext
if (existsSync(sidecar)) renameSync(sidecar, targetPath + ext)
}
}

if (candidates.length === 0) return

// Open the target DB for merging
let db: InstanceType<typeof BunDatabase>
try {
db = new BunDatabase(targetPath, { create: true, readwrite: true })
} catch {
// Non-fatal: worst case, a fresh DB is created
return
}

try {
// Disable foreign keys for the duration of the merge
db.run("PRAGMA foreign_keys = OFF")
db.run("PRAGMA journal_mode = WAL")

for (const candidate of candidates) {
try {
// Checkpoint the source WAL so ATTACH sees a clean state
let sourceDb: InstanceType<typeof BunDatabase> | undefined
try {
sourceDb = new BunDatabase(candidate.path, { readwrite: true })
sourceDb.run("PRAGMA wal_checkpoint(TRUNCATE)")
sourceDb.close()
sourceDb = undefined
} catch {
sourceDb?.close()
// If we can't checkpoint, try the merge anyway — ATTACH will read
// whatever is committed in the main file.
}

// Attach source and merge each table
const alias = "source_db"
db.run(`ATTACH DATABASE '${candidate.path.replace(/'/g, "''")}' AS ${alias}`)

try {
db.run("BEGIN")

// Get table list from the source to handle schema differences gracefully
const sourceTables = new Set(
(
db.query(`SELECT name FROM ${alias}.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`).all() as {
name: string
}[]
).map((r) => r.name),
)

for (const table of MERGE_TABLES) {
if (!sourceTables.has(table)) continue
db.run(`INSERT OR IGNORE INTO main."${table}" SELECT * FROM ${alias}."${table}"`)
}

db.run("COMMIT")
} catch {
try {
db.run("ROLLBACK")
} catch {
// Rollback may fail if transaction wasn't started
}
// Leave this source un-renamed for retry on next startup
try {
db.run(`DETACH DATABASE ${alias}`)
} catch {
// If detach fails too, bail on this source
}
continue
}

db.run(`DETACH DATABASE ${alias}`)

// Successfully merged — rename source to *.db.merged
try {
renameSync(candidate.path, candidate.path + ".merged")
// Clean up WAL/SHM sidecars
for (const ext of ["-wal", "-shm"]) {
const sidecar = candidate.path + ext
if (existsSync(sidecar)) unlinkSync(sidecar)
}
} catch {
// Non-fatal: the source stays as-is and will be skipped next time
// (it's already fully merged, so INSERT OR IGNORE is a no-op)
}
} catch {
// Non-fatal per source: skip this one and try the rest
continue
}
}

// Re-enable foreign keys
db.run("PRAGMA foreign_keys = ON")
} finally {
db.close()
}
}

Expand All @@ -108,18 +226,8 @@ export function path() {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return join(Global.Path.data, Flag.OPENCODE_DB)
}
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return join(Global.Path.data, "opencode.db")
if (STABLE_CHANNELS.has(InstallationChannel))
return join(Global.Path.data, `opencode-${InstallationChannel}.db`)
// All non-stable channels (local dev branches) share a single DB to avoid
// session fragmentation when switching branches.
const target = join(Global.Path.data, "opencode-local.db")
consolidateLocalDb(target)
const target = join(Global.Path.data, "opencode.db")
consolidateChannelDbs(target)
return target
}

Expand Down
Loading