GitNexus/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts
Gergő Magyar 21a52af1d4
fix(lbug): ship FTS per-platform and recover in-place native aborts (#3274)
* fix(lbug): pin Ladybug core so Dependabot cannot ship a skewed FTS artifact

The extension version is a separate upstream constant. Ignore daily core bumps and fail the pairing gate when the committed manifest does not name the installed core.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): make doctor and CI FTS gates resolve the packaged artifact

Doctor and the REQUIRE_FTS file gates still treated an empty ~/.lbdb as
unavailable, which would turn three CI jobs red once analyze stops
installing into that tree.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): name native-abort and tuple-missing so analyze cannot mis-advise

The CLI summary's trailing else treated every unknown skip reason as a
missing extension. New crash and platform causes must get their own
remedies, not a network-install hint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): delete the dead read-path FTS index create

ensureFTSIndex had no production callers and swallowed read-only
CREATE_FTS_INDEX failures, which hid the only signal that a reader
tried to write.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): vendor per-platform FTS artifacts so analyze needs no host install

Keyword search depended on a CDN fetch into ~/.lbdb. Shipping the five
published tuples inside the package makes air-gapped and ignore-scripts
installs load the same artifact the publish gate checksums.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): load the packaged FTS artifact before any network install

Analyze still required a CDN fetch into ~/.lbdb even when the package
already shipped the file. FTS now path-loads the vendored tuple first
and records source labels so a later truncated home copy cannot steal
the diagnosis.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): diagnose a core/extension version skew instead of a missing runtime

A structurally valid FTS artifact whose path version disagrees with the
packaged pin must name both versions, not prescribe VC++ or OpenSSL.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): stamp an FTS phase so repair stays usable after an in-place abort

A native CREATE_FTS_INDEX abort leaves no skip reason; the next run infers
it from the dirty flag, and --repair-fts must not treat that phase as a
half-written graph.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): park an in-place FTS crash WAL without wiping the graph

An FTS abort after a successful checkpoint must reopen the live index on
macOS, Windows, and Linux. Staging never parks the live WAL; readers keep
today's large-WAL refusal.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): refuse read-only opens of an FTS-poisoned WAL

MCP and serve cannot repair a leftover in-place abort. Fail before the
native open and name --repair-fts, on macOS, Windows, and Linux.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): name a vendor-neutral Windows OpenSSL prerequisite

OQ1 is unanswered here so GitNexus does not ship OpenSSL DLLs. Windows
FTS now asks for a system OpenSSL 3 runtime instead of Git Bash PATH.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(lbug): inject the FTS vendor root and redact it on HTTP and MCP

Path-loaded artifacts no longer vary with HOME. Tests pass an injected
vendor tree and assert search warnings never leak a filesystem path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(lbug): document load-only as the global FTS install default

Analyze still overrides to auto. Packaged per-platform artifacts load
before any network install on macOS, Windows, and Linux.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(lbug): format the FTS install-policy README table

Prettier does not run on Markdown in pre-commit, so the U10 table wrap
needs its own formatting commit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): skip FTS CREATE after a persisted native abort

A recovered analyze run was retrying CREATE_FTS_INDEX from skipReason
alone. Keep that skip until --repair-fts, fail closed on unsupported
tuples, and honor the checkpoint warrant for park/repair.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): honor checkpoint flushed warrant and align FTS tests with packaged vendor

A no-op CHECKPOINT must not satisfy the FTS park warrant, and CI still asserted HOME-only FTS isolation after analyze started path-LOADing the packaged artifact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(lbug): accept a nonempty incremental write set in the #2790 recovery check

FTS-phase recovery can incremental-add files (changed=0, added=1). That is not the #2790 empty-diff wipe skip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): compare FTS home versions to the core pin and tighten the publish filename gate

Ladybug's ~/.lbdb/extension directory is the runtime/core version; treating it as the artifact version false-diagnosed skew. The publish guard now rejects a path-escaping filename the same way the fetch script does.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(lbug): seed FTS e2e fixtures from the packaged vendor artifact

A machine with no ~/.lbdb copy should still run the vendor-survivorship cases; the seed no longer depends on HOME or a network install.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3274)

Keep in-place FTS abort evidence after persist so a second CREATE abort
cannot fail-open readers, and close the CLI, loader, embed, and e2e gaps
the review called out.

Note: full npm test hit Ladybug worker-pool startup failures under memory
pressure; tsc and 180 targeted unit tests passed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3274)

Run the vendored-path symlink guard on the OS matrix, put e2e HOME
fixtures on Ladybug's real extension layout, pin the embed crash-WAL
gate before the writable open, and let analyze writers park through
missing-shadow recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): keep --repair-fts CI green after vendored-first FTS

Never-installed warning fixtures must not inspect a packaged vendor binary, and a failed dirty restamp must not abort an otherwise successful --repair-fts run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): give the #1169 analyze e2e the same 90s Windows budget as its sibling

The first #1169 persist-meta case was still on a 60s spawn/it budget and was killed banner-only on windows-latest after the FTS warning fixture no longer failed the shard first.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(ci): reweight Windows shards after the FTS e2e grew

Vendored-first HOME fixtures pushed fts-extension-e2e to ~6 minutes on windows-latest, so the old 146s weight packed it with skills-e2e and blew the 20-minute watchdog.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 08:52:24 +01:00

1029 lines
42 KiB
TypeScript

/**
* 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<typeof import('../../src/core/lbug/schema.js')>(
'../../src/core/lbug/schema.js',
)),
...SCHEMA_MOCK,
});
async function mockLbugConfigForStorageVersion(overrides: Record<string, unknown>) {
const actual = await vi.importActual<typeof import('../../src/core/lbug/lbug-config.js')>(
'../../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: <p>.shadow - Error 2: <localized text>`, 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);
});
});