mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
node:sqlite enables SQLITE_OPEN_URI from Node 22.15 on. Below that -- 22.13 is the package floor -- a `file:...` location is a literal filename, so the immutable open failed as CANTOPEN and the copy quietly stood in for it. That was the right outcome by accident; the test asserted the newer behaviour and failed on the floor. The support question is now asked once per process, with an in-memory URI that touches no filesystem whichever answer comes back, and the immutable open is attempted only when the answer is yes. The test asks the same question rather than skipping, so both CI lines assert something: rows are correct either way, in place where URI filenames work and from a copy where they do not.
332 lines
12 KiB
TypeScript
332 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,
|
|
sqliteSupportsUriFilenames,
|
|
} 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 where it can and by copy where it cannot', ({ 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 there is nothing to go stale and nothing worth
|
|
// copying: immutable reads the source in place. node:sqlite only honours
|
|
// URI filenames on newer builds, and on the 22.13 floor the copy stands in.
|
|
expect(cachedDatabaseFiles()).toHaveLength(sqliteSupportsUriFilenames() ? 0 : 1)
|
|
})
|
|
|
|
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()
|
|
}
|
|
})
|
|
})
|