/** * Tests for WAL corruption detection in the doInitLbug schema creation loop. * * Before this fix, a corrupt WAL that threw during schema DDL was silently * logged as WARN. After the fix, `isWalCorruptionError` is checked first: * the DB is closed cleanly and an Error with `WAL_RECOVERY_SUGGESTION` is * thrown so the caller (serve / MCP / analyze) can exit with a clear message. * * Two test layers (same pattern as lbug-checkpoint-lifecycle.test.ts): * 1. Structural — grep the adapter source to verify the guard is wired in. * 2. Behavioural — vi.doMock + vi.resetModules to exercise the runtime path. */ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import fs from 'node:fs/promises'; import path from 'node:path'; import { STORAGE_VERSION_MISMATCH_SUGGESTION } from '../../src/core/lbug/lbug-config.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── const makeOpenMock = () => vi.fn(async () => ({ writeFile: vi.fn(async () => {}), close: vi.fn(async () => {}), })); const SCHEMA_MOCK = { NODE_TABLES: ['File', 'Function', 'Class'], REL_TABLE_NAME: 'CodeRelation', EMBEDDING_TABLE_NAME: 'Embedding', STALE_HASH_SENTINEL: '__stale__', SCHEMA_QUERIES: ['CREATE NODE TABLE IF NOT EXISTS File (id STRING, PRIMARY KEY(id))'], }; const schemaMockFactory = async () => ({ ...(await vi.importActual( '../../src/core/lbug/schema.js', )), ...SCHEMA_MOCK, }); async function mockLbugConfigForStorageVersion(overrides: Record) { const actual = await vi.importActual( '../../src/core/lbug/lbug-config.js', ); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ ...actual, isDbBusyError: vi.fn(() => false), isOpenRetryExhausted: vi.fn(() => false), isWalCorruptionError: vi.fn(() => false), waitForWindowsHandleRelease: vi.fn(async () => true), ...overrides, })); } function makeFsMock(dbPath: string) { const ENOENT = Object.assign(new Error(`ENOENT: ${dbPath}`), { code: 'ENOENT' }); return { default: { lstat: vi.fn(async () => { throw ENOENT; }), access: vi.fn(async () => { throw ENOENT; }), unlink: vi.fn(async () => {}), rename: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), open: makeOpenMock(), readdir: vi.fn(async () => []), readFile: vi.fn(async () => { throw ENOENT; }), }, }; } // ─── Structural tests ───────────────────────────────────────────────────────── describe('doInitLbug WAL corruption guard — structural', () => { let adapterSource: string; let schemaLoopBody: string; beforeAll(async () => { adapterSource = await fs.readFile( path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'), 'utf-8', ); // 3000-char window from the SCHEMA_QUERIES loop comfortably covers the // full catch block including the throw with WAL_RECOVERY_SUGGESTION. const loopIdx = adapterSource.indexOf('for (const schemaQuery of SCHEMA_QUERIES)'); schemaLoopBody = adapterSource.slice(loopIdx, loopIdx + 3000); }); it('imports isWalCorruptionError and WAL_RECOVERY_SUGGESTION from lbug-config', () => { expect(adapterSource).toMatch(/isWalCorruptionError/); expect(adapterSource).toMatch(/WAL_RECOVERY_SUGGESTION/); expect(adapterSource).toMatch(/from '\.\/lbug-config\.js'/); }); it('calls isWalCorruptionError inside the schema creation loop catch block', () => { expect(schemaLoopBody).toMatch(/isWalCorruptionError\(err\)/); }); it('WAL guard calls safeClose() to avoid leaving an open handle', () => { expect(schemaLoopBody).toMatch(/await safeClose\(\)/); }); it('WAL guard resets open connection state', () => { expect(schemaLoopBody).toMatch(/resetOpenConnectionState\(\)/); }); it('WAL guard throws with WAL_RECOVERY_SUGGESTION in the message', () => { expect(schemaLoopBody).toMatch(/WAL_RECOVERY_SUGGESTION/); expect(schemaLoopBody).toMatch(/throw new Error/); }); it('WAL guard appears BEFORE the generic schema-warning logger.warn', () => { const walGuardIdx = schemaLoopBody.indexOf('isWalCorruptionError(err)'); // Avoid multi-byte emoji — search for the text portion only const warnIdx = schemaLoopBody.indexOf('Schema creation warning'); expect(walGuardIdx).toBeGreaterThan(-1); expect(warnIdx).toBeGreaterThan(-1); expect(walGuardIdx).toBeLessThan(warnIdx); }); it('refuses a read-only FTS crash before preflight', () => { const readOnlyBlock = adapterSource.slice(adapterSource.indexOf('if (readOnly)')); const refuseIdx = readOnlyBlock.indexOf('assertReadOnlyFtsCrashSafe(dbPath)'); const preflightIdx = readOnlyBlock.indexOf('preflightLbugSidecars'); expect(refuseIdx).toBeGreaterThan(-1); expect(preflightIdx).toBeGreaterThan(refuseIdx); }); it('writable missing-shadow reopen can pass FTS crash evidence; read-only must not', () => { const evidenceStart = adapterSource.indexOf('const writableFtsCrashWalEvidence'); const writableStart = adapterSource.indexOf('const reopenWritableAfterMissingShadow'); const readOnlyStart = adapterSource.indexOf('const reopenReadOnlyAfterMissingShadow'); expect(evidenceStart).toBeGreaterThan(-1); expect(writableStart).toBeGreaterThan(evidenceStart); expect(readOnlyStart).toBeGreaterThan(-1); expect(adapterSource.slice(evidenceStart, writableStart + 600)).toMatch( /fts-inplace-checkpointed/, ); expect(adapterSource.slice(writableStart, writableStart + 600)).toMatch( /writableFtsCrashWalEvidence/, ); expect(adapterSource.slice(readOnlyStart, evidenceStart)).not.toMatch( /fts-inplace-checkpointed/, ); }); it('imports throwIfStorageVersionMismatch and uses it in the schema catch', () => { expect(adapterSource).toMatch(/throwIfStorageVersionMismatch/); expect(schemaLoopBody).toMatch(/isStorageVersionMismatchError\(err\)/); expect(schemaLoopBody).toMatch(/throwIfStorageVersionMismatch\(err\)/); const mismatchIdx = schemaLoopBody.indexOf('isStorageVersionMismatchError(err)'); const warnIdx = schemaLoopBody.indexOf('Schema creation warning'); expect(mismatchIdx).toBeGreaterThan(-1); expect(mismatchIdx).toBeLessThan(warnIdx); }); }); // ─── Behavioural tests ──────────────────────────────────────────────────────── describe('doInitLbug WAL corruption guard — behavioural', () => { afterEach(() => { vi.doUnmock('fs/promises'); vi.doUnmock('../../src/core/lbug/schema.js'); vi.doUnmock('../../src/core/lbug/lbug-config.js'); vi.doUnmock('../../src/core/lbug/extension-loader.js'); vi.doUnmock('../../src/core/logger.js'); vi.resetModules(); vi.clearAllMocks(); }); it('throws with WAL recovery message when a schema query raises a WAL corruption error', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-wal-schema-throw/lbug'; const walError = new Error( 'Runtime exception: Corrupted wal file. Read out invalid WAL record type.', ); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; const conn = { query: vi.fn().mockRejectedValueOnce(walError).mockResolvedValue(queryResult), close: vi.fn(async () => {}), }; const db = { close: vi.fn(async () => {}) }; vi.doMock('fs/promises', () => makeFsMock(dbPath)); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: vi.fn(async () => ({ db, conn })), closeLbugConnection: vi.fn(async () => {}), isDbBusyError: vi.fn(() => false), isOpenRetryExhausted: vi.fn(() => false), isWalCorruptionError: vi.fn((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); return /corrupt.*wal|invalid.*wal.*record/i.test(msg); }), WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', waitForWindowsHandleRelease: vi.fn(async () => true), isStorageVersionMismatchError: vi.fn(() => false), throwIfStorageVersionMismatch: vi.fn(), STORAGE_VERSION_MISMATCH_SUGGESTION: '', })); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: vi.fn(async () => true), getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); // Catch the error once and assert both patterns in the message. // (mockRejectedValueOnce is consumed on the first call, so a second // initLbug call would succeed — test both patterns in one shot.) const err = await adapter.initLbug(dbPath).catch((e: unknown) => e); expect(err).toBeInstanceOf(Error); expect((err as Error).message).toMatch(/LadybugDB WAL corruption detected/); expect((err as Error).message).toMatch(/gitnexus analyze/); }); it('does NOT throw for unrecognised schema errors — logs warn and continues', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-wal-schema-nonwal/lbug'; const genericError = new Error('some unrelated schema warning'); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; let callCount = 0; const conn = { query: vi.fn(async () => { callCount++; if (callCount === 1) throw genericError; return queryResult; }), close: vi.fn(async () => {}), }; const db = { close: vi.fn(async () => {}) }; const warnMock = vi.fn(); vi.doMock('fs/promises', () => makeFsMock(dbPath)); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: vi.fn(async () => ({ db, conn })), closeLbugConnection: vi.fn(async () => {}), isDbBusyError: vi.fn(() => false), isOpenRetryExhausted: vi.fn(() => false), isWalCorruptionError: vi.fn(() => false), // always false → generic warn path WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', waitForWindowsHandleRelease: vi.fn(async () => true), isStorageVersionMismatchError: vi.fn(() => false), throwIfStorageVersionMismatch: vi.fn(), STORAGE_VERSION_MISMATCH_SUGGESTION: '', })); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: vi.fn(async () => true), getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); // Must resolve without throwing — non-WAL schema errors are swallowed (logged as WARN) await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('Schema creation warning')); await adapter.closeLbug(); }); it('quarantines the WAL and retries writable schema creation when shadow sidecar is missing', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-writable-shadow-missing/lbug'; const missingShadowError = new Error( `IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`, ); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; const firstConn = { query: vi.fn().mockRejectedValueOnce(missingShadowError).mockResolvedValue(queryResult), close: vi.fn(async () => {}), }; const firstDb = { close: vi.fn(async () => {}) }; const recoveredConn = { query: vi.fn(async () => queryResult), close: vi.fn(async () => {}), }; const recoveredDb = { close: vi.fn(async () => {}) }; const openLbugConnectionMock = vi .fn() .mockResolvedValueOnce({ db: firstDb, conn: firstConn }) .mockResolvedValueOnce({ db: recoveredDb, conn: recoveredConn }); const fsMock = makeFsMock(dbPath); const ensureMock = vi.fn(async () => false); const warnMock = vi.fn(); vi.doMock('fs/promises', () => fsMock); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: async (handle: { conn: typeof firstConn; db: typeof firstDb }) => { await handle.conn.close(); await handle.db.close(); }, isDbBusyError: vi.fn(() => false), isOpenRetryExhausted: vi.fn(() => false), isWalCorruptionError: vi.fn(() => false), WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', waitForWindowsHandleRelease: vi.fn(async () => true), isStorageVersionMismatchError: vi.fn(() => false), throwIfStorageVersionMismatch: vi.fn(), STORAGE_VERSION_MISMATCH_SUGGESTION: '', })); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: ensureMock, getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); expect(openLbugConnectionMock).toHaveBeenCalledTimes(2); expect(fsMock.default.rename).toHaveBeenCalledWith( `${dbPath}.wal`, expect.stringContaining(`${dbPath}.wal.missing-shadow.`), ); expect(recoveredConn.query).toHaveBeenCalledWith(SCHEMA_MOCK.SCHEMA_QUERIES[0]); expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining('Schema creation warning')); await adapter.closeLbug(); }); it('skips schema DDL and uses load-only FTS policy for read-only opens', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-readonly-schema-skip/lbug'; const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; const conn = { query: vi.fn(async () => queryResult), close: vi.fn(async () => {}), }; const db = { close: vi.fn(async () => {}) }; const openLbugConnectionMock = vi.fn(async () => ({ db, conn })); const ensureMock = vi.fn(async () => false); const warnMock = vi.fn(); vi.doMock('fs/promises', () => makeFsMock(dbPath)); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: vi.fn(async () => {}), isDbBusyError: vi.fn(() => false), isOpenRetryExhausted: vi.fn(() => false), isWalCorruptionError: vi.fn(() => false), WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', waitForWindowsHandleRelease: vi.fn(async () => true), isStorageVersionMismatchError: vi.fn(() => false), throwIfStorageVersionMismatch: vi.fn(), STORAGE_VERSION_MISMATCH_SUGGESTION: '', })); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: ensureMock, getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.withLbugDb(dbPath, async () => 'ok', { readOnly: true })).resolves.toBe( 'ok', ); expect(openLbugConnectionMock).toHaveBeenCalledWith(expect.anything(), dbPath, { readOnly: true, }); expect(conn.query).not.toHaveBeenCalledWith(SCHEMA_MOCK.SCHEMA_QUERIES[0]); expect(ensureMock).toHaveBeenCalledWith(expect.any(Function), 'fts', 'FTS', { policy: 'load-only', }); expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining('Schema creation warning')); await adapter.closeLbug(); }); it('replays dirty shadow pages with a temporary writable open before read-only serving', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-readonly-shadow-replay/lbug'; const shadowReplayError = new Error( "Runtime exception: Couldn't replay shadow pages under read-only mode. Please re-open the database with read-write mode to replay shadow pages.", ); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; const readOnlyConn1 = { query: vi.fn().mockRejectedValueOnce(shadowReplayError), close: vi.fn(async () => {}), }; const readOnlyDb1 = { close: vi.fn(async () => {}) }; const writableConn = { query: vi.fn(async () => queryResult), close: vi.fn(async () => {}), }; const writableDb = { close: vi.fn(async () => {}) }; const readOnlyConn2 = { query: vi.fn(async () => queryResult), close: vi.fn(async () => {}), }; const readOnlyDb2 = { close: vi.fn(async () => {}) }; const openLbugConnectionMock = vi .fn() .mockResolvedValueOnce({ db: readOnlyDb1, conn: readOnlyConn1 }) .mockResolvedValueOnce({ db: writableDb, conn: writableConn }) .mockResolvedValueOnce({ db: readOnlyDb2, conn: readOnlyConn2 }); const ensureMock = vi.fn(async () => false); vi.doMock('fs/promises', () => makeFsMock(dbPath)); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: async (handle: { conn: typeof readOnlyConn1; db: typeof readOnlyDb1; }) => { await handle.conn.close(); await handle.db.close(); }, isDbBusyError: vi.fn(() => false), isOpenRetryExhausted: vi.fn(() => false), isWalCorruptionError: vi.fn(() => false), WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', waitForWindowsHandleRelease: vi.fn(async () => true), isStorageVersionMismatchError: vi.fn(() => false), throwIfStorageVersionMismatch: vi.fn(), STORAGE_VERSION_MISMATCH_SUGGESTION: '', })); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: ensureMock, getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.withLbugDb(dbPath, async () => 'ok', { readOnly: true })).resolves.toBe( 'ok', ); expect(openLbugConnectionMock).toHaveBeenNthCalledWith(1, expect.anything(), dbPath, { readOnly: true, }); expect(openLbugConnectionMock).toHaveBeenNthCalledWith(2, expect.anything(), dbPath); expect(openLbugConnectionMock).toHaveBeenNthCalledWith(3, expect.anything(), dbPath, { readOnly: true, }); expect(readOnlyConn1.close).toHaveBeenCalled(); expect(readOnlyDb1.close).toHaveBeenCalled(); expect(writableConn.query).toHaveBeenCalledWith('MATCH (n) RETURN n LIMIT 1'); expect(writableConn.close).toHaveBeenCalled(); expect(writableDb.close).toHaveBeenCalled(); expect(readOnlyConn2.query).toHaveBeenCalledWith('MATCH (n) RETURN n LIMIT 1'); expect(ensureMock).toHaveBeenCalledWith(expect.any(Function), 'fts', 'FTS', { policy: 'load-only', }); await adapter.closeLbug(); }); it('quarantines the WAL and reopens read-only when the shadow sidecar is missing', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-readonly-shadow-missing/lbug'; const missingShadowError = new Error( `IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`, ); const readOnlyConn = { query: vi.fn().mockRejectedValueOnce(missingShadowError), close: vi.fn(async () => {}), }; const readOnlyDb = { close: vi.fn(async () => {}) }; const recoveredConn = { query: vi.fn(async () => ({ getAll: vi.fn(async () => []), close: vi.fn() })), close: vi.fn(async () => {}), }; const recoveredDb = { close: vi.fn(async () => {}) }; const openLbugConnectionMock = vi .fn() .mockResolvedValueOnce({ db: readOnlyDb, conn: readOnlyConn, }) .mockResolvedValueOnce({ db: recoveredDb, conn: recoveredConn, }); const fsMock = makeFsMock(dbPath); vi.doMock('fs/promises', () => fsMock); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: async (handle: { conn: typeof readOnlyConn; db: typeof readOnlyDb }) => { await handle.conn.close(); await handle.db.close(); }, isDbBusyError: vi.fn(() => false), isOpenRetryExhausted: vi.fn(() => false), isWalCorruptionError: vi.fn(() => false), WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', waitForWindowsHandleRelease: vi.fn(async () => true), isStorageVersionMismatchError: vi.fn(() => false), throwIfStorageVersionMismatch: vi.fn(), STORAGE_VERSION_MISMATCH_SUGGESTION: '', })); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: vi.fn(async () => false), getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.withLbugDb(dbPath, async () => 'ok', { readOnly: true })).resolves.toBe( 'ok', ); expect(openLbugConnectionMock).toHaveBeenCalledTimes(2); expect(readOnlyConn.close).toHaveBeenCalled(); expect(readOnlyDb.close).toHaveBeenCalled(); expect(fsMock.default.rename).toHaveBeenCalledWith( `${dbPath}.wal`, expect.stringContaining(`${dbPath}.wal.missing-shadow.`), ); await adapter.closeLbug(); }); it('calls safeClose() (db.close) when WAL corruption is detected mid-schema', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-wal-schema-state/lbug'; const walError = new Error('Corrupted wal file. Read out invalid WAL record type.'); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; const conn = { query: vi.fn().mockRejectedValueOnce(walError).mockResolvedValue(queryResult), close: vi.fn(async () => {}), }; const db = { close: vi.fn(async () => {}) }; vi.doMock('fs/promises', () => makeFsMock(dbPath)); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: vi.fn(async () => ({ db, conn })), closeLbugConnection: vi.fn(async () => {}), isDbBusyError: vi.fn(() => false), isOpenRetryExhausted: vi.fn(() => false), isWalCorruptionError: vi.fn((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); return /corrupt.*wal|invalid.*wal.*record/i.test(msg); }), WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', waitForWindowsHandleRelease: vi.fn(async () => true), isStorageVersionMismatchError: vi.fn(() => false), throwIfStorageVersionMismatch: vi.fn(), STORAGE_VERSION_MISMATCH_SUGGESTION: '', })); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: vi.fn(async () => true), getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.initLbug(dbPath)).rejects.toThrow(/LadybugDB WAL corruption/); // safeClose was called — db.close is its final step expect(db.close).toHaveBeenCalled(); }); }); // ─── Symmetric WAL-size gate (PR #1747 review, D2) ────────────────────────── // // Both reopenWritableAfterMissingShadow and reopenReadOnlyAfterMissingShadow // must refuse to quarantine a WAL larger than TINY_ORPHAN_WAL_BYTES (4096). // The pre-PR behavior silently quarantined any size of WAL during recovery — // on the read-only path this could permanently orphan uncheckpointed pages // because a later writable open would see a `clean` state and never replay. const TINY_ORPHAN_WAL_BYTES_TEST = 4 * 1024; /** * Variant of makeFsMock where the `.wal` path is classified by * inspectLbugSidecars based on a chosen size. Use to drive the * `orphan-wal` vs `tiny-orphan-wal` branches of refuseLargeWalQuarantine * without spinning up real files. */ function makeFsMockWithWalSize( dbPath: string, walBytes: number | 'missing', shadowBytes: number | 'missing' = 'missing', ) { const ENOENT = Object.assign(new Error(`ENOENT: ${dbPath}`), { code: 'ENOENT' }); const isWal = (p: string): boolean => p === `${dbPath}.wal`; const isShadow = (p: string): boolean => p === `${dbPath}.shadow`; return { default: { lstat: vi.fn(async () => { throw ENOENT; }), access: vi.fn(async (p: string) => { if (isWal(p) && walBytes !== 'missing') return; if (isShadow(p) && shadowBytes !== 'missing') return; throw ENOENT; }), stat: vi.fn(async (p: string) => { if (isWal(p)) { if (walBytes === 'missing') throw ENOENT; return { size: walBytes }; } if (isShadow(p)) { if (shadowBytes === 'missing') throw ENOENT; return { size: shadowBytes }; } return { size: 0 }; }), unlink: vi.fn(async () => {}), rename: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), open: makeOpenMock(), readdir: vi.fn(async () => []), readFile: vi.fn(async () => { throw ENOENT; }), }, }; } describe('Symmetric WAL-size gate during missing-shadow recovery (PR #1747 D2)', () => { afterEach(() => { vi.resetModules(); vi.unstubAllEnvs(); }); const setupShadowMissingRecovery = ( dbPath: string, walBytes: number | 'missing', opts: { errorMessage?: string; shadowBytes?: number | 'missing' } = {}, ) => { const missingShadowError = new Error( opts.errorMessage ?? `IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`, ); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; const firstConn = { query: vi.fn().mockRejectedValueOnce(missingShadowError).mockResolvedValue(queryResult), close: vi.fn(async () => {}), }; const firstDb = { close: vi.fn(async () => {}) }; const recoveredConn = { query: vi.fn(async () => queryResult), close: vi.fn(async () => {}), }; const recoveredDb = { close: vi.fn(async () => {}) }; const openLbugConnectionMock = vi .fn() .mockResolvedValueOnce({ db: firstDb, conn: firstConn }) .mockResolvedValueOnce({ db: recoveredDb, conn: recoveredConn }); const fsMock = makeFsMockWithWalSize(dbPath, walBytes, opts.shadowBytes ?? 'missing'); const warnMock = vi.fn(); vi.doMock('fs/promises', () => fsMock); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ openLbugConnection: openLbugConnectionMock, closeLbugConnection: async (handle: { conn: typeof firstConn; db: typeof firstDb }) => { await handle.conn.close(); await handle.db.close(); }, isDbBusyError: vi.fn(() => false), isOpenRetryExhausted: vi.fn(() => false), isWalCorruptionError: vi.fn(() => false), WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', waitForWindowsHandleRelease: vi.fn(async () => true), isStorageVersionMismatchError: vi.fn(() => false), throwIfStorageVersionMismatch: vi.fn(), STORAGE_VERSION_MISMATCH_SUGGESTION: '', })); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: vi.fn(async () => false), getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); return { fsMock, openLbugConnectionMock, warnMock }; }; it('writable recovery: refuses to quarantine a large WAL (4097 bytes) and throws shadow-recovery message', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-large-wal-writable/lbug'; const { fsMock, warnMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST + 1); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.initLbug(dbPath)).rejects.toThrow( /LadybugDB checkpoint sidecar is missing/, ); expect(fsMock.default.rename).not.toHaveBeenCalled(); expect(warnMock).toHaveBeenCalledWith( expect.stringContaining('refusing to quarantine large WAL'), ); expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('writable recovery')); }); it('read-only recovery: refuses to quarantine a large WAL (4097 bytes) and throws shadow-recovery message', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-large-wal-readonly/lbug'; const { fsMock, warnMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST + 1); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect( adapter.withLbugDb(dbPath, async () => 'unreached', { readOnly: true }), ).rejects.toThrow(/LadybugDB checkpoint sidecar is missing/); expect(fsMock.default.rename).not.toHaveBeenCalled(); expect(warnMock).toHaveBeenCalledWith( expect.stringContaining('refusing to quarantine large WAL'), ); expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('read-only recovery')); }); it('writable recovery: WAL at exactly TINY_ORPHAN_WAL_BYTES (4096 bytes) is treated as tiny and quarantined', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-boundary-tiny/lbug'; const { fsMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); expect(fsMock.default.rename).toHaveBeenCalledWith( `${dbPath}.wal`, expect.stringContaining(`${dbPath}.wal.missing-shadow.`), ); await adapter.closeLbug(); }); it('writable recovery: WAL at TINY_ORPHAN_WAL_BYTES + 1 (4097 bytes) is treated as orphan-wal and refused', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-boundary-large/lbug'; const { fsMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST + 1); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.initLbug(dbPath)).rejects.toThrow(); expect(fsMock.default.rename).not.toHaveBeenCalled(); }); it('tiny-WAL recovery path: writable recovery still quarantines and proceeds for a 1024-byte WAL', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-tiny-wal/lbug'; const { fsMock } = setupShadowMissingRecovery(dbPath, 1024); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); expect(fsMock.default.rename).toHaveBeenCalledWith( `${dbPath}.wal`, expect.stringContaining(`${dbPath}.wal.missing-shadow.`), ); await adapter.closeLbug(); }); // ─── Windows-format missing-shadow recovery (issue #2382) ───────────────── // // On Windows the native engine reports a missing shadow as // `Cannot open file. path:

.shadow - Error 2: `, not the // POSIX `: No such file or directory`. Before the fix isMissingShadowSidecarError // missed that form, so the read-only open on serve repo-switch rethrew the raw // error as an HTTP 500 and never quarantined the orphan WAL — the repo stayed // broken. These drive the SAME recovery path with the Windows string through // both consumers (read-only + writable) and pin the present-shadow guard (KTD7). const windowsError2 = (dbPath: string) => `IO exception: Cannot open file. path: ${dbPath}.shadow - Error 2: The system cannot find the file specified.`; it('read-only: recognizes the Windows Error 2 form and self-heals a tiny orphan WAL', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-win-selfheal/lbug'; const { fsMock } = setupShadowMissingRecovery(dbPath, 1024, { errorMessage: windowsError2(dbPath), }); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.withLbugDb(dbPath, async () => 'ok', { readOnly: true })).resolves.toBe( 'ok', ); expect(fsMock.default.rename).toHaveBeenCalledWith( `${dbPath}.wal`, expect.stringContaining(`${dbPath}.wal.missing-shadow.`), ); await adapter.closeLbug(); }); it('read-only: Windows Error 2 with a large WAL yields the actionable message (not the raw 500)', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-win-largewal/lbug'; const { fsMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST + 1, { errorMessage: windowsError2(dbPath), }); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect( adapter.withLbugDb(dbPath, async () => 'unreached', { readOnly: true }), ).rejects.toThrow(/LadybugDB checkpoint sidecar is missing/); expect(fsMock.default.rename).not.toHaveBeenCalled(); }); it('writable: Windows Error 2 flows through the same guarded recovery (blast-radius R4)', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-win-writable/lbug'; const { fsMock } = setupShadowMissingRecovery(dbPath, 1024, { errorMessage: windowsError2(dbPath), }); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); expect(fsMock.default.rename).toHaveBeenCalledWith( `${dbPath}.wal`, expect.stringContaining(`${dbPath}.wal.missing-shadow.`), ); await adapter.closeLbug(); }); it('KTD7 guard: refuses to quarantine when the shadow is present on disk (data-loss guard)', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-win-shadow-present/lbug'; const { fsMock, warnMock } = setupShadowMissingRecovery(dbPath, 1024, { errorMessage: windowsError2(dbPath), shadowBytes: 64, }); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect( adapter.withLbugDb(dbPath, async () => 'unreached', { readOnly: true }), // Present-shadow refusal throws the present-but-unreachable message (S2), // NOT the "sidecar is missing / rebuild" message — the shadow is present. ).rejects.toThrow(/LadybugDB checkpoint sidecar is present but unreachable/); expect(fsMock.default.rename).not.toHaveBeenCalled(); expect(warnMock).toHaveBeenCalledWith( expect.stringContaining('the .shadow sidecar is present on disk'), ); }); }); const NATIVE_STORAGE_VERSION_MISMATCH = 'Runtime exception: Trying to read a database file with a different version. Database file version: 43, Current build storage version: 42'; describe('doInitLbug storage-version fail-fast — behavioural', () => { afterEach(() => { vi.doUnmock('fs/promises'); vi.doUnmock('../../src/core/lbug/schema.js'); vi.doUnmock('../../src/core/lbug/lbug-config.js'); vi.doUnmock('../../src/core/lbug/extension-loader.js'); vi.doUnmock('../../src/core/logger.js'); vi.resetModules(); vi.clearAllMocks(); }); it('throws the rebuild hint when a writable schema query raises a storage-version mismatch', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-storage-version-schema/lbug'; const versionError = new Error(NATIVE_STORAGE_VERSION_MISMATCH); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; const conn = { query: vi.fn().mockRejectedValueOnce(versionError).mockResolvedValue(queryResult), close: vi.fn(async () => {}), }; const db = { close: vi.fn(async () => {}) }; const warnMock = vi.fn(); vi.doMock('fs/promises', () => makeFsMock(dbPath)); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); await mockLbugConfigForStorageVersion({ openLbugConnection: vi.fn(async () => ({ db, conn })), closeLbugConnection: vi.fn(async () => {}), }); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: vi.fn(async () => true), getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const err = await adapter.initLbug(dbPath).catch((e: unknown) => e); expect(err).toBeInstanceOf(Error); expect((err as Error).message).toContain(STORAGE_VERSION_MISMATCH_SUGGESTION); expect((err as Error).message).toMatch(/database file with a different version/i); expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining('Schema creation warning')); expect(db.close).toHaveBeenCalled(); // Schema DDL is not retried. A second query is the CHECKPOINT inside safeClose. expect(conn.query).toHaveBeenNthCalledWith(1, SCHEMA_MOCK.SCHEMA_QUERIES[0]); expect( conn.query.mock.calls.filter((call) => call[0] === SCHEMA_MOCK.SCHEMA_QUERIES[0]), ).toHaveLength(1); }); it('throws the rebuild hint when writable openLbugConnection raises a storage-version mismatch', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-storage-version-writable-open/lbug'; const versionError = new Error(NATIVE_STORAGE_VERSION_MISMATCH); const openLbugConnection = vi.fn(async () => { throw versionError; }); const warnMock = vi.fn(); vi.doMock('fs/promises', () => makeFsMock(dbPath)); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); await mockLbugConfigForStorageVersion({ openLbugConnection, closeLbugConnection: vi.fn(async () => {}), }); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: vi.fn(async () => true), getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect(adapter.initLbug(dbPath)).rejects.toThrow(STORAGE_VERSION_MISMATCH_SUGGESTION); expect(openLbugConnection).toHaveBeenCalledTimes(1); expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining('Schema creation warning')); }); it('throws the rebuild hint on a read-only open storage-version mismatch without lock-retrying', async () => { vi.resetModules(); const dbPath = '/tmp/gitnexus-lbug-storage-version-readonly/lbug'; const versionError = new Error(NATIVE_STORAGE_VERSION_MISMATCH); const openLbugConnection = vi.fn(async () => { throw versionError; }); vi.doMock('fs/promises', () => makeFsMock(dbPath)); vi.doMock('../../src/core/lbug/schema.js', schemaMockFactory); await mockLbugConfigForStorageVersion({ openLbugConnection, closeLbugConnection: vi.fn(async () => {}), }); vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ extensionManager: { ensure: vi.fn(async () => true), getCapabilities: vi.fn(() => []), reset: vi.fn(), }, })); vi.doMock('../../src/core/logger.js', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await expect( adapter.withLbugDb(dbPath, async () => 'unreached', { readOnly: true }), ).rejects.toThrow(STORAGE_VERSION_MISMATCH_SUGGESTION); expect(openLbugConnection).toHaveBeenCalledTimes(1); }); });