mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
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.
330 lines
12 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|