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.
This commit is contained in:
iamtoruk 2026-08-18 10:28:31 -07:00
parent 267749b112
commit 9bfe9cc492
3 changed files with 248 additions and 100 deletions

View file

@ -16,7 +16,7 @@
- **DeepSeek Harness (`dsh`) is now a supported provider.** Reads DeepSeek's open-source agent harness from `~/.dsh/sessions` (`DSH_HOME` relocates the root), both the default zstd logs and the uncompressed `session.jsonl` variant. A `.zstd` log is a concatenation of independent zstd frames, one per write batch, so it is decoded frame by frame behind a structural frame scan and a torn trailing frame from a crashed writer is ignored rather than failing the file (needs Node 22.15+ for `zlib` zstd; below that dsh is skipped with a notice instead of counted as $0). One call per `(turn, step)`, with the step's final `assistant/message` usage superseding the streamed `assistant/chunk` sample of the same call rather than adding to it, the model taken from the message that served the step, and reasoning tokens billed at the output rate. DSH records tokens but no cost, so calls are priced from the shared tables. The events a forked session replays from its parent are skipped, since codeburn already counts the parent's own log. The session format is pinned at version 0 upstream with no compatibility implied, so a log stamped with any other version is skipped with a notice instead of read under today's assumptions.
### Changed
- **SQLite providers now survive read-only database parents.** CodeBurn keeps the existing read-only open as the fast path, then fingerprints and caches the database plus `-wal`/`-shm` siblings only when SQLite reports that the source directory cannot create its sidecars. The cache has one bounded entry per source path and reuses it while the main-plus-WAL fingerprint is unchanged; the original provider database is never opened writable or modified. Read-only discovery failures are identified separately from missing databases and produce one rate-limited stderr notice.
- **SQLite providers now survive read-only database parents.** A read-only SQLite open is not read-only on disk: on a WAL database SQLite must create `<db>-shm` and `<db>-wal` in the database's own directory, so a source on read-only media, under restrictive permissions, or inside a Flatpak/snap confinement failed with `attempt to write a readonly database` (or `unable to open database file` when a `-wal` was present without its `-shm`), and both discovery sites swallowed it — the provider read as "not installed" rather than as an error. That covers cursor, cursor-agent, opencode, goose, warp, kilo-code, zerostack and the copilot agent-traces database. The direct open stays the fast path and is byte-identical when it succeeds. When it fails for want of sidecars: a database with no WAL frames to lose is opened in place with `immutable=1`, which costs nothing and cannot go stale; a database with a non-empty `-wal` is copied with its `-wal` into the CodeBurn cache and read there, so its un-checkpointed rows are never silently dropped. The copy costs one database's worth of disk and is taken once per change — it is keyed by the main-plus-WAL fingerprint, published under a fingerprint-stamped name so a refresh never overwrites a copy another process is reading, and superseded copies are evicted once a day has passed without a read, keeping at most one predecessor. If the cache itself cannot be written, the database is skipped with a notice naming it and the reason rather than in silence. The original provider database is never opened writable or modified.
- **Codex rollouts parse across worker threads too, and the workload gate now takes bytes or files.** Codex is the bigger half of a real cold parse — a 4 GB rollout corpus against 1.8 GB of Claude sessions — and it was still decoding one file at a time. A whole-file rollout decode now runs on the same pool, against an empty dedup set, and comes back with the calls, the dedup keys it claimed, and the codex-cache entry it would have written; the parent installs all three in the serial loop's order, so `codex-results.json` and every payload come out byte-identical to a serial run. Cross-file state stays where it was: a forked rollout replaying its parent's token_count history collides on the parent's keys and is re-parsed in-process, and no worker ever touches the cache module's per-directory state. Files the Codex cache can serve exactly or resume into from a byte offset never reach a worker — they read a few KB and the resume state belongs to the parent. The workload gate is now pending BYTES alone (200 MB), not file count: 250 pending files holding under a megabyte between them spawned threads that made the run ~5% slower, while a few hundred huge rollouts were being turned away. The count takes `max(pendingFiles / 50, pendingBytes / 200 MB)`, and the per-thread memory budget is derived per parse as `clamp(256 MB, 2 × average pending file + 128 MB, 1 GB)` rather than a flat 256 MB — a 260 MB rollout peaks near 430 MB in its worker and scales linearly with the pool, so the flat figure over-subscribed exactly the workload this adds. The decision is per provider, and at most one pool is alive at a time.
- **A large cold Claude parse now runs across worker threads.** Reading, decoding and line-parsing a session JSONL is per-file work that never touches anything shared, so it moves onto `worker_threads`; each worker ships its parsed turns back as a JSON string and the parent installs them in the exact order the serial loop would. Everything with cross-file state — the streaming-message dedup, canonical project paths, spawn links, PR correlation, progress saves — stays on the main thread, and a file whose message ids were already claimed by an earlier file (or whose worker failed) is simply re-parsed in-process, so the session cache and every payload are identical either way. On a 6 GB corpus a cold `status` drops from 27.5s to 14.8s with peak RSS up 2.27 GB → 2.52 GB. Threads only engage for a genuinely large cold parse: never with under 200 MB behind the pending whole-file re-parses, 2 or fewer cores, or under 4 GB of available memory — so warm and incremental runs are untouched and spawn nothing. Otherwise the count is `min(cores - 1, min(0.25 × available, 2 GB) / 256 MB, pendingFiles / 50)`, where available is `process.availableMemory()` (cgroup-aware in containers) rather than free memory, which on macOS reports free pages and would switch the feature on and off between runs. `CODEBURN_PARSE_WORKERS=0` forces the serial parse and `CODEBURN_PARSE_WORKERS=N` forces N (capped at the core count), both bypassing every gate; `CODEBURN_VERBOSE=1` prints the resolved count and why.
- **A warm launch rewrites only the month that changed, and a ranged query reads only the months it can report on.** Per-provider shards still meant one appended session republished that provider's entire history — 95 MB for Claude on a 6 GB corpus. Each provider's shard is now split again by the UTC month of the cached session's FIRST turn, a bucket that never moves as a session grows, so an append rewrites one month. Every shard records the newest month it holds, which lets `--period today/week` skip the shards that cannot contribute a turn to the range; the skipped months stay on disk untouched across the save, and providers whose cache is the only surviving record (durable) or whose parse fingerprint moved are always read in full. Remaining shards are read concurrently. Existing v8 and v7 caches are re-laid-out losslessly on first load and the old layout removed once the new one is published: nothing re-parses.

View file

@ -1,7 +1,8 @@
import { createRequire } from 'node:module'
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, utimesSync } from 'node:fs'
import { createHash, randomBytes } from 'node:crypto'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { getCodeburnCacheDir } from './cache-dir.js'
@ -157,24 +158,27 @@ type DatabaseFingerprint = {
ino: number
mtimeMs: number
sizeBytes: number
walBytes: number
}
type CachedDatabaseMetadata = {
version: number
sourcePath: string
fingerprint: DatabaseFingerprint
/// A superseded copy is dropped once it has gone this long without being used.
/// The delay is what keeps a concurrent reader of the previous copy from having
/// its file yanked out from under it.
const CACHE_ENTRY_MAX_AGE_MS = 24 * 60 * 60 * 1000
const warnedDatabases = new Set<string>()
/// One notice per source path per run: a provider may discover many sessions
/// from the same database, and the first notice already says what happened.
function warnSqliteOnce(path: string, message: string): void {
if (warnedDatabases.has(path)) return
warnedDatabases.add(path)
process.stderr.write(message)
}
const SQLITE_CACHE_VERSION = 1
const warnedReadonlyDatabases = new Set<string>()
/// A read-only SQLite connection can still need sidecar files. This notice is
/// intentionally once per source path: a provider may discover many sessions
/// from the same database, and the fallback is already doing the useful work.
/// A read-only SQLite connection can still need sidecar files.
export function warnSqliteReadonlyOnce(path: string): void {
if (warnedReadonlyDatabases.has(path)) return
warnedReadonlyDatabases.add(path)
process.stderr.write(
warnSqliteOnce(
path,
`codeburn: SQLite database ${path} is in a read-only directory and needs sidecar files; using a cache copy when necessary. ` +
'The original database is not modified.\n',
)
@ -186,6 +190,10 @@ function errorCode(err: unknown): string | undefined {
return typeof code === 'string' ? code : undefined
}
function describeError(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/// This deliberately mirrors fingerprintSqliteFile/fingerprintFile in
/// session-cache.ts. openDatabase is synchronous, so the fallback uses the
/// synchronous fs APIs only after the direct open has already failed; the
@ -203,6 +211,7 @@ function fingerprintDatabase(path: string): DatabaseFingerprint {
ino: main.ino,
mtimeMs: wal ? Math.max(main.mtimeMs, wal.mtimeMs) : main.mtimeMs,
sizeBytes: main.size + (wal?.size ?? 0),
walBytes: wal?.size ?? 0,
}
}
@ -211,42 +220,17 @@ function sameFingerprint(a: DatabaseFingerprint, b: DatabaseFingerprint): boolea
a.dev === b.dev &&
a.ino === b.ino &&
a.mtimeMs === b.mtimeMs &&
a.sizeBytes === b.sizeBytes
a.sizeBytes === b.sizeBytes &&
a.walBytes === b.walBytes
)
}
function isDatabaseFingerprint(value: unknown): value is DatabaseFingerprint {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Partial<DatabaseFingerprint>
return (
typeof candidate.dev === 'number' &&
typeof candidate.ino === 'number' &&
typeof candidate.mtimeMs === 'number' &&
typeof candidate.sizeBytes === 'number'
)
}
function readCachedMetadata(path: string): CachedDatabaseMetadata | null {
try {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (typeof parsed !== 'object' || parsed === null) return null
const candidate = parsed as { version?: unknown; sourcePath?: unknown; fingerprint?: unknown }
if (
candidate.version !== SQLITE_CACHE_VERSION ||
typeof candidate.sourcePath !== 'string' ||
!isDatabaseFingerprint(candidate.fingerprint)
) return null
return { version: SQLITE_CACHE_VERSION, sourcePath: candidate.sourcePath, fingerprint: candidate.fingerprint }
} catch {
return null
}
}
function unlinkIfPresent(path: string): void {
function unlinkQuietly(path: string): void {
try {
unlinkSync(path)
} catch (err) {
if (errorCode(err) !== 'ENOENT') throw err
} catch {
// Already gone, or still held open by another CodeBurn on Windows. Either
// way the next run's eviction pass gets another chance at it.
}
}
@ -260,26 +244,85 @@ function copyOptionalFile(sourcePath: string, destinationPath: string): boolean
}
}
function sourceKeyOf(sourcePath: string): string {
return createHash('sha256').update(sourcePath, 'utf8').digest('hex').slice(0, 32)
}
/// The copy is named after the source it came from AND the fingerprint it was
/// taken at, so a refresh publishes a new file rather than overwriting one that
/// another process may still have open.
function cacheEntryName(sourceKey: string, fingerprint: DatabaseFingerprint): string {
const parts = `${fingerprint.dev}:${fingerprint.ino}:${fingerprint.mtimeMs}:${fingerprint.sizeBytes}:${fingerprint.walBytes}`
return `${sourceKey}.${createHash('sha256').update(parts).digest('hex').slice(0, 16)}.db`
}
function dropCopy(cacheDir: string, name: string): void {
unlinkQuietly(join(cacheDir, name))
unlinkQuietly(join(cacheDir, name + '-wal'))
unlinkQuietly(join(cacheDir, name + '-shm'))
}
/// Superseded copies are cleaned up here rather than by overwriting them: keep
/// the one in use plus at most one predecessor, and drop 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 rather than copy time.
function evictSupersededCopies(cacheDir: string, sourceKey: string, keepName: string): void {
let names: string[]
try {
names = readdirSync(cacheDir)
} catch {
return
}
const now = Date.now()
const superseded: { name: string, mtimeMs: number }[] = []
for (const name of names) {
if (!name.endsWith('.db') || name === keepName) continue
let mtimeMs: number
try {
mtimeMs = statSync(join(cacheDir, name)).mtimeMs
} catch {
continue
}
if (name.startsWith(`${sourceKey}.`)) superseded.push({ name, mtimeMs })
else if (now - mtimeMs > CACHE_ENTRY_MAX_AGE_MS) dropCopy(cacheDir, name)
}
superseded.sort((a, b) => b.mtimeMs - a.mtimeMs)
for (const [index, entry] of superseded.entries()) {
if (index > 0 || now - entry.mtimeMs > CACHE_ENTRY_MAX_AGE_MS) dropCopy(cacheDir, entry.name)
}
}
/// A concurrent CodeBurn may have published the same copy first. The name is
/// the fingerprint, so the content is identical by construction and losing that
/// race is not an error.
function publish(tempPath: string, finalPath: string): void {
try {
renameSync(tempPath, finalPath)
} catch (err) {
if (!existsSync(finalPath)) throw err
}
}
function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint): string {
const cacheDir = join(getCodeburnCacheDir(), 'sqlite-ro')
mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
const sourceKey = createHash('sha256').update(sourcePath, 'utf8').digest('hex')
const cachePath = join(cacheDir, `${sourceKey}.db`)
const metadataPath = `${cachePath}.json`
const cached = readCachedMetadata(metadataPath)
if (
existsSync(cachePath) &&
cached?.sourcePath === sourcePath &&
sameFingerprint(cached.fingerprint, fingerprint)
) {
const sourceKey = sourceKeyOf(sourcePath)
const name = cacheEntryName(sourceKey, fingerprint)
const cachePath = join(cacheDir, name)
if (existsSync(cachePath)) {
const now = new Date()
try {
utimesSync(cachePath, now, now)
} catch {
// mtime is only the eviction clock; a copy we cannot touch still reads.
}
evictSupersededCopies(cacheDir, sourceKey, name)
return cachePath
}
const tempBase = `${cachePath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}`
const tempWal = tempBase + '-wal'
const tempMetadata = `${metadataPath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}`
try {
copyFileSync(sourcePath, tempBase)
const copiedWal = copyOptionalFile(sourcePath + '-wal', tempWal)
@ -291,28 +334,22 @@ function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint)
throw new Error('SQLite database changed while preparing its read-only cache copy')
}
unlinkIfPresent(cachePath)
unlinkIfPresent(cachePath + '-wal')
unlinkIfPresent(cachePath + '-shm')
renameSync(tempBase, cachePath)
if (copiedWal) renameSync(tempWal, cachePath + '-wal')
const metadata: { version: number; sourcePath: string; fingerprint: DatabaseFingerprint } = {
version: SQLITE_CACHE_VERSION,
sourcePath,
fingerprint,
}
writeFileSync(tempMetadata, JSON.stringify(metadata), { encoding: 'utf8', mode: 0o600 })
renameSync(tempMetadata, metadataPath)
// The -wal goes first: a reader that can see the database must never find it
// without the sidecar holding its most recent rows.
if (copiedWal) publish(tempWal, cachePath + '-wal')
publish(tempBase, cachePath)
evictSupersededCopies(cacheDir, sourceKey, name)
return cachePath
} finally {
unlinkIfPresent(tempBase)
unlinkIfPresent(tempWal)
unlinkIfPresent(tempMetadata)
unlinkQuietly(tempBase)
unlinkQuietly(tempWal)
}
}
function openReadonlyCache(path: string, originalError: unknown): DatabaseSyncInstance {
const Driver = DatabaseSync
if (Driver === null) throw new Error(getSqliteLoadError())
let fingerprint: DatabaseFingerprint
try {
fingerprint = fingerprintDatabase(path)
@ -321,9 +358,29 @@ function openReadonlyCache(path: string, originalError: unknown): DatabaseSyncIn
// inaccessible between the failed query and the fallback probe.
throw originalError
}
const cachedPath = readOnlyCachePath(path, fingerprint)
const Driver = DatabaseSync
if (Driver === null) throw new Error(getSqliteLoadError())
// An absent or empty -wal holds no frames, so there is nothing to go stale and
// nothing worth copying: immutable lets SQLite skip the -shm it cannot create
// and read the source in place.
if (fingerprint.walBytes === 0) {
try {
return new Driver(`${pathToFileURL(path).href}?immutable=1`, { readOnly: true })
} catch {
// Older node:sqlite builds may not enable URI filenames. Copy instead.
}
}
let cachedPath: string
try {
cachedPath = readOnlyCachePath(path, fingerprint)
} catch (err) {
warnSqliteOnce(
path,
`codeburn: SQLite database ${path} is in a read-only directory and its cache copy could not be written ` +
`(${describeError(err)}); skipping this database.\n`,
)
throw originalError
}
return new Driver(cachedPath, { readOnly: true })
}

View file

@ -1,4 +1,4 @@
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
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'
@ -43,6 +43,7 @@ beforeEach(async () => {
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 })
@ -69,6 +70,27 @@ function createOpenWalDatabase(dbPath: string): NativeDatabase {
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')
@ -100,6 +122,10 @@ function makeSourceParentReadOnly(skip: (reason?: string) => void): boolean {
return true
}
function makeSourceParentWritable(): void {
chmodSync(sourceRoot, 0o755)
}
function cachedDatabaseFiles(): string[] {
try {
return readdirSync(join(cacheRoot, 'sqlite-ro')).filter(name => name.endsWith('.db'))
@ -132,7 +158,7 @@ describe('SQLite read-only parent fallback', () => {
expect(cachedDatabaseFiles()).toEqual([])
})
it('reads a WAL database when the parent is read-only and sidecars are absent', ({ skip }) => {
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)
@ -143,7 +169,9 @@ describe('SQLite read-only parent fallback', () => {
expect(existsSync(dbPath + '-wal')).toBe(false)
expect(existsSync(dbPath + '-shm')).toBe(false)
expect(cachedDatabaseFiles()).toHaveLength(1)
// 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 }) => {
@ -156,25 +184,10 @@ describe('SQLite read-only parent fallback', () => {
})
it('reads un-checkpointed WAL rows when the parent is read-only and the -shm is absent', ({ skip }) => {
const originDir = join(sourceRoot, 'origin')
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)
// A database copied off a live source (snapshot, rsync, unclean unmount) keeps
// its -wal but not its -shm. SQLite reports that as SQLITE_CANTOPEN, not
// 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')
copyFileSync(originPath, dbPath)
copyFileSync(originPath + '-wal', dbPath + '-wal')
writer.close()
expect(existsSync(dbPath + '-shm')).toBe(false)
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
const db = openDatabase(dbPath)
@ -189,14 +202,92 @@ describe('SQLite read-only parent fallback', () => {
it('reuses an unchanged fallback copy instead of copying the database again', ({ skip }) => {
const dbPath = join(sourceRoot, 'state.vscdb')
createClosedWalDatabase(dbPath)
writeUncheckpointedWalDatabase(dbPath)
if (!makeSourceParentReadOnly(skip)) return
expect(readValue(dbPath)).toBe(1)
const cachedPath = join(cacheRoot, 'sqlite-ro', cachedDatabaseFiles()[0]!)
const firstMtime = statSync(cachedPath).mtimeMs
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(statSync(cachedPath).mtimeMs).toBe(firstMtime)
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', () => {