fix(sqlite): fall back for a read-only parent that reports SQLITE_CANTOPEN

A read-only parent with a -wal but no -shm fails as SQLITE_CANTOPEN (14),
not SQLITE_READONLY (8), so the fallback never ran and the un-checkpointed
rows in the -wal stayed invisible. openReadonlyCache already re-throws the
original error when the database itself is missing, which is the other
CANTOPEN, so widening the trigger keeps that case distinguishable.

Also stops copying the source -shm: SQLite rebuilds the wal-index from the
-wal in the writable cache directory, so the copy is dead weight.
This commit is contained in:
iamtoruk 2026-08-18 10:18:03 -07:00
parent cc6e048479
commit 267749b112
2 changed files with 47 additions and 9 deletions

View file

@ -142,6 +142,16 @@ export function isSqliteReadonlyError(err: unknown): boolean {
)
}
/// A read-only parent reports SQLITE_READONLY_DIRECTORY when it must create the
/// sidecars from scratch, but SQLITE_CANTOPEN when a `-wal` is present and the
/// `-shm` it needs to index it is not. openReadonlyCache re-throws the original
/// error when the database itself is missing, which is the other CANTOPEN.
function isSqliteSidecarError(err: unknown): boolean {
if (isSqliteReadonlyError(err)) return true
const errcode = (err as { errcode?: unknown } | null)?.errcode
return typeof errcode === 'number' && (errcode & 0xff) === 14
}
type DatabaseFingerprint = {
dev: number
ino: number
@ -268,13 +278,11 @@ function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint)
const tempBase = `${cachePath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}`
const tempWal = tempBase + '-wal'
const tempShm = tempBase + '-shm'
const tempMetadata = `${metadataPath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}`
try {
copyFileSync(sourcePath, tempBase)
const copiedWal = copyOptionalFile(sourcePath + '-wal', tempWal)
const copiedShm = copyOptionalFile(sourcePath + '-shm', tempShm)
// Do not publish a cache made from a moving database. A live WAL writer will
// normally make the direct open succeed once its sidecars exist; this check
@ -288,7 +296,6 @@ function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint)
unlinkIfPresent(cachePath + '-shm')
renameSync(tempBase, cachePath)
if (copiedWal) renameSync(tempWal, cachePath + '-wal')
if (copiedShm) renameSync(tempShm, cachePath + '-shm')
const metadata: { version: number; sourcePath: string; fingerprint: DatabaseFingerprint } = {
version: SQLITE_CACHE_VERSION,
@ -301,7 +308,6 @@ function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint)
} finally {
unlinkIfPresent(tempBase)
unlinkIfPresent(tempWal)
unlinkIfPresent(tempShm)
unlinkIfPresent(tempMetadata)
}
}
@ -331,10 +337,10 @@ export function openDatabase(path: string): SqliteDatabase {
try {
db = new DatabaseSync(path, { readOnly: true })
} catch (err) {
if (!isSqliteReadonlyError(err)) throw err
if (!isSqliteSidecarError(err)) throw err
fallbackUsed = true
warnSqliteReadonlyOnce(path)
db = openReadonlyCache(path, err)
warnSqliteReadonlyOnce(path)
}
try {
db.exec?.('PRAGMA busy_timeout = 1000')
@ -347,16 +353,16 @@ export function openDatabase(path: string): SqliteDatabase {
try {
return db.prepare(sql).all(...params) as T[]
} catch (err) {
if (!isSqliteReadonlyError(err)) throw err
if (!isSqliteSidecarError(err)) throw err
if (fallbackUsed) throw err
fallbackUsed = true
warnSqliteReadonlyOnce(path)
try {
db.close()
} catch {
// The failed connection may already have been closed by node:sqlite.
}
db = openReadonlyCache(path, err)
warnSqliteReadonlyOnce(path)
try {
db.exec?.('PRAGMA busy_timeout = 1000')
} catch {

View file

@ -1,4 +1,4 @@
import { chmodSync, existsSync, readdirSync, statSync } from 'node:fs'
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { join } from 'node:path'
@ -155,6 +155,38 @@ describe('SQLite read-only parent fallback', () => {
expect(cachedDatabaseFiles()).toEqual([])
})
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_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)
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')
createClosedWalDatabase(dbPath)