codeburn/tests/sqlite-readonly-parent.test.ts
iamtoruk 9bfe9cc492 fix(sqlite): read a read-only parent in place when there is no WAL to lose
Four things the copy fallback got wrong.

A database whose -wal is absent or empty has no un-checkpointed frames, so
there is nothing to go stale and nothing worth copying: immutable=1 opens the
source in place and SQLite skips the -shm it cannot create. The copy is now
taken only when a non-empty -wal exists, which is the case where dropping it
would lose rows.

A copy is published under a name carrying its fingerprint, so refreshing one
never has to unlink a file another process may still hold open, which Windows
does not allow. The -wal is published before the database so a reader can
never see the database without the sidecar holding its newest rows, and losing
a publish race to an identical copy is not an error. That removes the metadata
sidecar: the name is the fingerprint.

Superseded copies are evicted rather than overwritten -- the one in use plus at
most one predecessor, and anything untouched for a day, which is also what a
source path that no longer exists looks like. Reuse touches the copy, so its
mtime is last use.

A cache directory that cannot be written no longer fails the same way the bug
did. It emits the once-per-database notice naming the database and the reason
before the database is skipped, instead of going quiet.
2026-08-18 10:28:31 -07:00

330 lines
12 KiB
TypeScript

import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync, utimesSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
isSqliteReadonlyError,
openDatabase,
} from '../src/sqlite.js'
import {
discoverSqliteSessions,
type SqliteProviderConfig,
} from '../src/providers/sqlite-session-parser.js'
const requireForTest = createRequire(import.meta.url)
type NativeDatabase = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void; all(...params: unknown[]): unknown[] }
close(): void
}
type NativeDatabaseCtor = new (path: string) => NativeDatabase
const { DatabaseSync: NativeDatabase } = requireForTest('node:sqlite') as {
DatabaseSync: NativeDatabaseCtor
}
let sourceRoot: string
let cacheRoot: string
let previousCacheDir: string | undefined
const openWriters: NativeDatabase[] = []
beforeEach(async () => {
sourceRoot = await mkdtemp(join(tmpdir(), 'codeburn-sqlite-source-'))
cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-sqlite-cache-'))
previousCacheDir = process.env['CODEBURN_CACHE_DIR']
process.env['CODEBURN_CACHE_DIR'] = cacheRoot
})
afterEach(async () => {
chmodSync(sourceRoot, 0o755)
chmodSync(cacheRoot, 0o755)
for (const writer of openWriters.splice(0)) writer.close()
await rm(sourceRoot, { recursive: true, force: true })
await rm(cacheRoot, { recursive: true, force: true })
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
})
function createClosedWalDatabase(dbPath: string): void {
const db = new NativeDatabase(dbPath)
db.exec('PRAGMA journal_mode=WAL')
db.exec('CREATE TABLE values_table (c INTEGER)')
db.prepare('INSERT INTO values_table (c) VALUES (?)').run(1)
db.close()
}
function createOpenWalDatabase(dbPath: string): NativeDatabase {
const db = new NativeDatabase(dbPath)
db.exec('PRAGMA journal_mode=WAL')
db.exec('CREATE TABLE values_table (c INTEGER)')
db.prepare('INSERT INTO values_table (c) VALUES (?)').run(1)
expect(existsSync(dbPath + '-wal')).toBe(true)
expect(existsSync(dbPath + '-shm')).toBe(true)
openWriters.push(db)
return db
}
/// A database plus a non-empty -wal and no -shm: what a snapshot, an rsync or an
/// unclean unmount of a live source leaves behind. The second row exists only in
/// the -wal, so dropping it would be silent data loss rather than an error.
function writeUncheckpointedWalDatabase(dbPath: string): void {
const originDir = join(sourceRoot, `origin-${readdirSync(sourceRoot).length}`)
mkdirSync(originDir)
const originPath = join(originDir, 'state.vscdb')
const writer = new NativeDatabase(originPath)
writer.exec('PRAGMA journal_mode=WAL')
writer.exec('CREATE TABLE values_table (c INTEGER)')
writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(1)
writer.exec('PRAGMA wal_checkpoint(TRUNCATE)')
writer.exec('PRAGMA wal_autocheckpoint=0')
writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(2)
copyFileSync(originPath, dbPath)
copyFileSync(originPath + '-wal', dbPath + '-wal')
writer.close()
expect(statSync(dbPath + '-wal').size).toBeGreaterThan(0)
expect(existsSync(dbPath + '-shm')).toBe(false)
}
function createDiscoveryDatabase(dbPath: string): void {
const db = new NativeDatabase(dbPath)
db.exec('PRAGMA journal_mode=WAL')
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
directory TEXT,
title TEXT,
time_created INTEGER,
parent_id TEXT,
time_archived INTEGER
)
`)
db.exec('CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data BLOB)')
db.exec('CREATE TABLE part (id INTEGER PRIMARY KEY, message_id TEXT, session_id TEXT, data BLOB)')
db.prepare(
'INSERT INTO session (id, directory, title, time_created, parent_id, time_archived) VALUES (?, ?, ?, ?, ?, ?)',
).run('session-1', '/tmp/project', 'Read-only fixture', Date.now(), null, null)
db.close()
}
function makeSourceParentReadOnly(skip: (reason?: string) => void): boolean {
chmodSync(sourceRoot, 0o555)
const mode = statSync(sourceRoot).mode & 0o777
if ((mode & 0o222) !== 0) {
skip(`SKIP: chmod 0555 did not make the fixture parent non-writable (mode ${mode.toString(8)})`)
return false
}
return true
}
function makeSourceParentWritable(): void {
chmodSync(sourceRoot, 0o755)
}
function cachedDatabaseFiles(): string[] {
try {
return readdirSync(join(cacheRoot, 'sqlite-ro')).filter(name => name.endsWith('.db'))
} catch {
return []
}
}
function readValue(dbPath: string): number {
const db = openDatabase(dbPath)
try {
const rows = db.query<{ c: number }>('SELECT c FROM values_table')
return rows[0]?.c ?? -1
} finally {
db.close()
}
}
describe('SQLite read-only parent fallback', () => {
it('keeps the existing writable-parent open behaviour', () => {
const dbPath = join(sourceRoot, 'state.vscdb')
createClosedWalDatabase(dbPath)
expect(existsSync(dbPath + '-wal')).toBe(false)
expect(existsSync(dbPath + '-shm')).toBe(false)
expect(readValue(dbPath)).toBe(1)
expect(existsSync(dbPath + '-wal')).toBe(true)
expect(existsSync(dbPath + '-shm')).toBe(true)
expect(cachedDatabaseFiles()).toEqual([])
})
it('reads a read-only parent with no -wal in place, without copying it', ({ skip }) => {
const dbPath = join(sourceRoot, 'state.vscdb')
createClosedWalDatabase(dbPath)
expect(existsSync(dbPath + '-wal')).toBe(false)
expect(existsSync(dbPath + '-shm')).toBe(false)
if (!makeSourceParentReadOnly(skip)) return
expect(readValue(dbPath)).toBe(1)
expect(existsSync(dbPath + '-wal')).toBe(false)
expect(existsSync(dbPath + '-shm')).toBe(false)
// No WAL frames exist, so immutable reads the source in place: nothing to go
// stale, nothing to copy.
expect(cachedDatabaseFiles()).toEqual([])
})
it('opens directly when a read-only parent already has WAL sidecars', ({ skip }) => {
const dbPath = join(sourceRoot, 'state.vscdb')
createOpenWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
expect(readValue(dbPath)).toBe(1)
expect(cachedDatabaseFiles()).toEqual([])
})
it('reads un-checkpointed WAL rows when the parent is read-only and the -shm is absent', ({ skip }) => {
// SQLite reports a -wal without its -shm as SQLITE_CANTOPEN, not
// SQLITE_READONLY, and the un-checkpointed row lives only in the -wal.
const dbPath = join(sourceRoot, 'state.vscdb')
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
const db = openDatabase(dbPath)
try {
expect(db.query<{ c: number }>('SELECT c FROM values_table ORDER BY c')).toEqual([{ c: 1 }, { c: 2 }])
} finally {
db.close()
}
expect(existsSync(dbPath + '-shm')).toBe(false)
expect(cachedDatabaseFiles()).toHaveLength(1)
})
it('reuses an unchanged fallback copy instead of copying the database again', ({ skip }) => {
const dbPath = join(sourceRoot, 'state.vscdb')
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
expect(readValue(dbPath)).toBe(1)
const first = cachedDatabaseFiles()
expect(first).toHaveLength(1)
const cachedPath = join(cacheRoot, 'sqlite-ro', first[0]!)
const firstIno = statSync(cachedPath).ino
expect(readValue(dbPath)).toBe(1)
expect(cachedDatabaseFiles()).toEqual(first)
expect(statSync(cachedPath).ino).toBe(firstIno)
})
it('publishes a refreshed copy beside the old one and keeps at most one predecessor', ({ skip }) => {
const dbPath = join(sourceRoot, 'state.vscdb')
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
expect(readValue(dbPath)).toBe(1)
const [first] = cachedDatabaseFiles()
const firstIno = statSync(join(cacheRoot, 'sqlite-ro', first!)).ino
// A changed source must not overwrite the copy a concurrent reader may still
// have open: Windows cannot unlink it, and the name is the fingerprint.
makeSourceParentWritable()
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
expect(readValue(dbPath)).toBe(1)
const second = cachedDatabaseFiles()
expect(second).toHaveLength(2)
expect(second).toContain(first)
expect(statSync(join(cacheRoot, 'sqlite-ro', first!)).ino).toBe(firstIno)
makeSourceParentWritable()
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
expect(readValue(dbPath)).toBe(1)
const third = cachedDatabaseFiles()
expect(third).toHaveLength(2)
expect(third).not.toContain(first)
})
it('evicts a copy left untouched for a day, including one whose source is gone', ({ skip }) => {
const dbPath = join(sourceRoot, 'state.vscdb')
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
expect(readValue(dbPath)).toBe(1)
const cacheDir = join(cacheRoot, 'sqlite-ro')
const predecessor = join(cacheDir, cachedDatabaseFiles()[0]!)
// A copy of a database that no longer exists is simply one nothing touches.
const orphan = join(cacheDir, `${'0'.repeat(32)}.deadbeefdeadbeef.db`)
writeFileSync(orphan, 'orphan')
const aDayAndAnHourAgo = new Date(Date.now() - 25 * 60 * 60 * 1000)
utimesSync(orphan, aDayAndAnHourAgo, aDayAndAnHourAgo)
makeSourceParentWritable()
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
expect(readValue(dbPath)).toBe(1)
expect(existsSync(orphan)).toBe(false)
expect(existsSync(predecessor)).toBe(true)
// A day without a read and the superseded copy goes too.
utimesSync(predecessor, aDayAndAnHourAgo, aDayAndAnHourAgo)
expect(readValue(dbPath)).toBe(1)
expect(existsSync(predecessor)).toBe(false)
expect(cachedDatabaseFiles()).toHaveLength(1)
})
it('says so instead of going quiet when the cache copy cannot be written', ({ skip }) => {
const dbPath = join(sourceRoot, 'state.vscdb')
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
chmodSync(cacheRoot, 0o555)
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
expect(() => readValue(dbPath)).toThrow()
const notices = stderr.mock.calls.filter(([chunk]) => String(chunk).includes('cache copy could not be written'))
expect(notices).toHaveLength(1)
expect(String(notices[0]?.[0])).toContain(dbPath)
} finally {
stderr.mockRestore()
chmodSync(cacheRoot, 0o755)
}
})
it('keeps a genuinely missing database distinguishable from SQLITE_READONLY', () => {
let thrown: unknown
try {
openDatabase(join(sourceRoot, 'missing.vscdb'))
} catch (err) {
thrown = err
}
expect(thrown).toBeDefined()
expect(isSqliteReadonlyError(thrown)).toBe(false)
expect(thrown).toMatchObject({ errcode: 14, message: 'unable to open database file' })
})
it('surfaces one read-only notice in SQLite discovery and still finds the session', async ({ skip }) => {
const dbPath = join(sourceRoot, 'state.db')
createDiscoveryDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
const config: SqliteProviderConfig = {
providerName: 'opencode',
displayName: 'OpenCode',
dbDir: sourceRoot,
dbFilePrefix: 'state',
}
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
const sessions = await discoverSqliteSessions(config)
expect(sessions).toHaveLength(1)
expect(sessions[0]?.path).toBe(`${dbPath}:session-1`)
expect(stderr.mock.calls.filter(([chunk]) => String(chunk).includes('read-only directory'))).toHaveLength(1)
await discoverSqliteSessions(config)
expect(stderr.mock.calls.filter(([chunk]) => String(chunk).includes('read-only directory'))).toHaveLength(1)
} finally {
stderr.mockRestore()
}
})
})