mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* 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>
1029 lines
42 KiB
TypeScript
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);
|
|
});
|
|
});
|