Skip to content

Commit 6f4a8e6

Browse files
quickbeardclaude
andauthored
fix(upload): read the session store when no codev process has it open (#378)
opencode.db runs in WAL mode. The Bun session reader opened it read-only, and a read-only connection cannot create the -wal/-shm side files a WAL read needs. While a codev process holds the store they exist; after the last one closes cleanly they are gone, and Bun's SQLite fails the first query with SQLITE_CANTOPEN — so `codev upload`, `codev upload --logs`, or a stats push with nothing else running found no sessions. The Bun driver now probes the read-only connection and, only on SQLITE_CANTOPEN, reopens read-write without create so SQLite recreates the side files as any WAL reader does; the reader still only runs SELECTs, and a missing store is never created. node:sqlite (the desktop sidecar's driver) already handles this case; the parity test now covers it for both drivers. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5b9e45a commit 6f4a8e6

2 files changed

Lines changed: 64 additions & 8 deletions

File tree

‎packages/codev-upload/src/sqlite.bun.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { SessionDb, SessionStatement } from "./sqlite"
33

44
// Bun driver for the session reader — see sqlite.ts for the split.
55
export function openSessionDb(path: string): SessionDb {
6-
const db = new Database(path, { readonly: true })
6+
const db = openReadable(path)
77
return {
88
prepare<R>(sql: string): SessionStatement<R> {
99
const stmt = db.prepare(sql)
@@ -13,4 +13,25 @@ export function openSessionDb(path: string): SessionDb {
1313
}
1414
}
1515

16+
// The store runs in WAL mode, and a read-only connection cannot create the
17+
// -wal/-shm side files a WAL read needs. While a codev process has the store
18+
// open they exist; after the last one closes cleanly they are gone, and Bun's
19+
// SQLite then fails the first query with SQLITE_CANTOPEN — `codev upload`
20+
// with nothing else running would find no sessions. Only in that case, reopen
21+
// read-write (never creating the database) so SQLite can recreate the side
22+
// files the way any reader of a WAL database does; the reader itself still
23+
// only runs SELECTs. node:sqlite (the desktop sidecar's driver) handles this
24+
// case on its own.
25+
function openReadable(path: string): Database {
26+
const readonly = new Database(path, { readonly: true })
27+
try {
28+
readonly.prepare("SELECT 1 FROM sqlite_master LIMIT 1").all()
29+
return readonly
30+
} catch (err) {
31+
readonly.close()
32+
if ((err as { code?: string }).code !== "SQLITE_CANTOPEN") throw err
33+
return new Database(path, { readwrite: true, create: false })
34+
}
35+
}
36+
1637
export type { SessionDb, SessionStatement } from "./sqlite"

‎packages/codev-upload/test/sqlite-node.test.ts‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Database } from "bun:sqlite"
22
import { describe, expect, test } from "bun:test"
3-
import { mkdtempSync, rmSync } from "node:fs"
3+
import { existsSync, mkdtempSync, rmSync } from "node:fs"
44
import { tmpdir } from "node:os"
55
import { join } from "node:path"
66
import { openSessionDb } from "../src/sqlite.bun"
@@ -22,6 +22,21 @@ function seedFixture(dir: string): string {
2222
return path
2323
}
2424

25+
// The store as it looks once every codev process has closed it: WAL mode,
26+
// fully checkpointed, and no -wal/-shm side files left beside it.
27+
function seedClosedWalFixture(dir: string): string {
28+
const path = join(dir, "wal.db")
29+
const seed = new Database(path, { create: true })
30+
seed.run("PRAGMA journal_mode=WAL")
31+
seed.run("CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, time_created INTEGER)")
32+
seed.run("INSERT INTO session VALUES ('s1', 'p1', 1), ('s2', 'p2', 2)")
33+
seed.run("PRAGMA wal_checkpoint(TRUNCATE)")
34+
seed.close()
35+
rmSync(`${path}-wal`, { force: true })
36+
rmSync(`${path}-shm`, { force: true })
37+
return path
38+
}
39+
2540
describe("sqlite driver parity", () => {
2641
test("bun driver answers the reader's query shapes", () => {
2742
const dir = mkdtempSync(join(tmpdir(), "codev-upload-sqlite-"))
@@ -39,11 +54,27 @@ describe("sqlite driver parity", () => {
3954
rmSync(dir, { recursive: true, force: true })
4055
})
4156

57+
test("bun driver reads a WAL store no codev process has open", () => {
58+
const dir = mkdtempSync(join(tmpdir(), "codev-upload-sqlite-wal-"))
59+
const db = openSessionDb(seedClosedWalFixture(dir))
60+
expect(db.prepare<{ id: string }>("SELECT id FROM session WHERE project_id = ?").all("p1")).toEqual([{ id: "s1" }])
61+
db.close()
62+
rmSync(dir, { recursive: true, force: true })
63+
})
64+
65+
test("bun driver never creates a missing store", () => {
66+
const dir = mkdtempSync(join(tmpdir(), "codev-upload-sqlite-missing-"))
67+
const path = join(dir, "missing.db")
68+
expect(() => openSessionDb(path)).toThrow()
69+
expect(existsSync(path)).toBe(false)
70+
rmSync(dir, { recursive: true, force: true })
71+
})
72+
4273
test("node driver (the desktop sidecar's) returns the same rows under real node", async () => {
4374
const node = Bun.which("node")
4475
if (!node) return // no node on this machine — the bun half still ran
4576
const dir = mkdtempSync(join(tmpdir(), "codev-upload-sqlite-node-"))
46-
const fixture = seedFixture(dir)
77+
const fixtures = [seedFixture(dir), seedClosedWalFixture(dir)]
4778
const built = join(dir, "sqlite.node.mjs")
4879
const build = Bun.spawnSync([
4980
"bun",
@@ -55,18 +86,22 @@ describe("sqlite driver parity", () => {
5586
])
5687
expect(build.exitCode).toBe(0)
5788

89+
// Both fixtures, including the WAL store with no side files left.
5890
const script = `
5991
import { openSessionDb } from ${JSON.stringify(built)}
60-
const db = openSessionDb(${JSON.stringify(fixture)})
61-
const rows = db.prepare("SELECT id FROM session WHERE project_id = ? ORDER BY time_created").all("p1")
62-
db.close()
63-
process.stdout.write(JSON.stringify(rows))
92+
const results = ${JSON.stringify(fixtures)}.map((fixture) => {
93+
const db = openSessionDb(fixture)
94+
const rows = db.prepare("SELECT id FROM session WHERE project_id = ? ORDER BY time_created").all("p1")
95+
db.close()
96+
return rows
97+
})
98+
process.stdout.write(JSON.stringify(results))
6499
`
65100
const run = Bun.spawnSync([node, "--experimental-sqlite", "--no-warnings", "--input-type=module", "-e", script])
66101
const stderr = run.stderr.toString()
67102
if (run.exitCode !== 0 && stderr.includes("No such built-in module")) return // node too old — skip
68103
expect(stderr).toBe("")
69-
expect(run.stdout.toString()).toBe(EXPECTED)
104+
expect(run.stdout.toString()).toBe(`[${EXPECTED},${EXPECTED}]`)
70105
rmSync(dir, { recursive: true, force: true })
71106
})
72107
})

0 commit comments

Comments
 (0)