GitNexus/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts
Copilot a26ac55fb0
fix(lbug): Recover gitnexus analyze from orphan LadybugDB sidecars when main DB file is missing (#1622)
* Initial plan

* fix: recover from orphan lbug sidecars on init

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6e8ea6e8-f9ab-46ff-9c1b-4d2c73a6452c

* test: strengthen orphan sidecar recovery coverage

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6e8ea6e8-f9ab-46ff-9c1b-4d2c73a6452c

* fix(lbug): only clean orphan sidecars when DB is missing

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* test(lbug): cover no-cleanup path when db file exists

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* test(lbug): use errno-shaped ENOENT mocks for sidecar recovery

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* test(lbug): cover partial sidecar and unlink-failure recovery cases

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* refactor(lbug): tighten ENOENT detection and test naming

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* test(lbug): normalize errno mock helpers across sidecar tests

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* docs(lbug): annotate orphan `.wal.checkpoint` cleanup provenance

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7edf5156-43e0-412d-87a4-bf4b2934deac

* test(lbug): clarify unlink-failure path test intent

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7edf5156-43e0-412d-87a4-bf4b2934deac

* fix(lbug): handle orphan-sidecar cleanup error paths explicitly

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/93294b2c-57f6-459c-8eb2-86e3b8920fb0

* refactor(lbug): extract errno and error-summary helpers

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/93294b2c-57f6-459c-8eb2-86e3b8920fb0

* test(lbug): expand non-ENOENT lstat coverage and remove magic number

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/93294b2c-57f6-459c-8eb2-86e3b8920fb0

* test(lbug): add native integration test for orphan sidecar recovery

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2dd28264-4604-430a-a249-af52afd29245

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(lbug): annotate best-effort catch in integration test cleanup

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2dd28264-4604-430a-a249-af52afd29245

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(lbug): add cross-process init lock for orphan sidecar cleanup with integration tests

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e4cbcfec-a252-449d-8d65-2f3570a253f8

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor(lbug): use INIT_LOCK_STALE_MS in stale lock detection and address review feedback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e4cbcfec-a252-449d-8d65-2f3570a253f8

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style(lbug): fix Prettier line-length violation in acquireInitLock fs.open call

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a140b567-0e9b-4ec9-a158-9fe6b8685ec2

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(lbug): ensure parent directory exists before creating init lock file

acquireInitLock tried to create `${dbPath}.init.lock` using O_CREAT | O_EXCL,
but on a fresh repo the parent directory (`.gitnexus/`) doesn't exist yet —
the mkdir call was inside the locked section. This caused ENOENT failures
on all platforms (Windows, macOS, Ubuntu) during `gitnexus analyze`.

Move mkdir to before the lock file creation attempt.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6883dc3c-36eb-4907-bcd8-61d23e2c641a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(lbug): verify acquireInitLock succeeds when parent directory does not exist

Adds an integration test proving the fix from the previous commit:
acquireInitLock now creates the parent directory before attempting
to create the lock file, preventing ENOENT on fresh repos.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6883dc3c-36eb-4907-bcd8-61d23e2c641a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-16 11:45:32 +01:00

874 lines
27 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
const makeErrnoError = <TCode extends string>(code: TCode, message: string) =>
Object.assign(new Error(message), { code });
/** Stub file handle returned by mocked `fs.open` for the init lock. */
const makeOpenMock = () =>
vi.fn(async () => ({
writeFile: vi.fn(async () => {}),
close: vi.fn(async () => {}),
}));
/** Standard `fs/promises` mock for tests that only need doInitLbug to succeed. */
const mockFsForInit = (dbPath: string) => {
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, lstat '${dbPath}'`,
);
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: vi.fn(async () => {
throw ENOENT_ERROR;
}),
unlink: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
};
describe('lbug adapter CHECKPOINT lifecycle', () => {
afterEach(() => {
vi.doUnmock('fs/promises');
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('removes orphan sidecars when main DB file is missing before opening LadybugDB', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
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 unlinkMock = vi.fn(async () => {});
const accessMock = vi.fn(async () => {
throw ENOENT_ERROR;
});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = 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 adapter.initLbug(dbPath);
expect(accessMock).toHaveBeenCalledWith(dbPath);
// Unlink called for: .shadow sidecar, .wal.checkpoint sidecar, init lock release
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
expect(warnMock).toHaveBeenCalledTimes(2);
expect(warnMock).toHaveBeenCalledWith(
'GitNexus: removed orphan sidecar lbug.shadow (no main DB file present)',
);
expect(warnMock).toHaveBeenCalledWith(
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
);
await adapter.closeLbug();
});
it('skips orphan sidecar cleanup when db access fails with non-ENOENT errors', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar-eacces/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
const EACCES_ERROR = makeErrnoError('EACCES', `EACCES: permission denied, access '${dbPath}'`);
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 accessMock = vi.fn(async () => {
throw EACCES_ERROR;
});
const unlinkMock = vi.fn(async () => {});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = 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 adapter.initLbug(dbPath);
expect(accessMock).toHaveBeenCalledWith(dbPath);
// Only the init lock release calls unlink — sidecar cleanup was skipped
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock.mock.calls[0]?.[0]).toContain(
'GitNexus: unable to verify main DB file before orphan sidecar cleanup (EACCES); skipping cleanup:',
);
await adapter.closeLbug();
});
it('does not remove sidecars when main db file is present', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-present/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
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 accessMock = vi.fn(async () => {});
const unlinkMock = vi.fn(async () => {});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = 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 adapter.initLbug(dbPath);
expect(accessMock).toHaveBeenCalledWith(dbPath);
// Only the init lock release calls unlink — no sidecar cleanup needed
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
expect(warnMock).not.toHaveBeenCalled();
await adapter.closeLbug();
});
it.each([
{
code: 'EPERM',
message: 'operation not permitted',
dbPath: '/tmp/gitnexus-lbug-lstat-eperm/lbug',
},
{
code: 'EACCES',
message: 'permission denied',
dbPath: '/tmp/gitnexus-lbug-lstat-eacces/lbug',
},
])('throws when db path lstat fails with non-ENOENT %s', async ({ code, message, dbPath }) => {
vi.resetModules();
const LSTAT_ERROR = makeErrnoError(code, `${code}: ${message}, lstat '${dbPath}'`);
const accessMock = vi.fn(async () => {});
const unlinkMock = vi.fn(async () => {});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw LSTAT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => {
throw new Error('should not be called');
}),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
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(new RegExp(message, 'i'));
expect(accessMock).not.toHaveBeenCalled();
expect(unlinkMock).not.toHaveBeenCalled();
});
it('handles partial orphan sidecar state and removes only present sidecars', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-partial-sidecar/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
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 accessMock = vi.fn(async () => {
throw ENOENT_ERROR;
});
const unlinkMock = vi.fn(async (target: string) => {
if (target.endsWith('.shadow')) throw ENOENT_ERROR;
});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = 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 adapter.initLbug(dbPath);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock).toHaveBeenCalledWith(
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
);
await adapter.closeLbug();
});
it('proceeds to openLbugConnection when orphan sidecar unlink fails', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-sidecar-unlink-fail/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
const EPERM_ERROR = makeErrnoError(
'EPERM',
`EPERM: operation not permitted, unlink '${dbPath}.shadow'`,
);
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 accessMock = vi.fn(async () => {
throw ENOENT_ERROR;
});
const unlinkMock = vi.fn(async () => {
throw EPERM_ERROR;
});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
const openLbugConnectionMock = vi.fn(async () => ({ db, conn }));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: openLbugConnectionMock,
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = 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 adapter.initLbug(dbPath);
expect(unlinkMock).toHaveBeenCalledTimes(3);
expect(warnMock).toHaveBeenCalledTimes(3);
expect(warnMock.mock.calls[0]?.[0]).toContain(
'GitNexus: failed to remove orphan sidecar lbug.shadow (EPERM) while main DB file is missing; LadybugDB open may still fail:',
);
expect(warnMock.mock.calls[1]?.[0]).toContain(
'GitNexus: failed to remove orphan sidecar lbug.wal.checkpoint (EPERM) while main DB file is missing; LadybugDB open may still fail:',
);
expect(warnMock.mock.calls[2]?.[0]).toContain('GitNexus: failed to release init lock (EPERM)');
expect(openLbugConnectionMock).toHaveBeenCalledWith(expect.anything(), dbPath);
await adapter.closeLbug();
});
it('drains and closes CHECKPOINT result before closing connection and database handles', async () => {
vi.resetModules();
const events: string[] = [];
const checkpointResult = {
getAll: vi.fn(async () => {
events.push('checkpoint:getAll');
return [];
}),
close: vi.fn(() => {
events.push('checkpoint:close');
}),
};
const genericResult = {
getAll: vi.fn(async () => []),
close: vi.fn(),
};
const conn = {
query: vi.fn(async (sql: string) => {
if (sql === 'CHECKPOINT') {
events.push('checkpoint:query');
return checkpointResult;
}
return genericResult;
}),
close: vi.fn(async () => {
events.push('conn:close');
}),
};
const db = {
close: vi.fn(async () => {
events.push('db:close');
}),
};
mockFsForInit('/tmp/gitnexus-lbug-checkpoint-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug('/tmp/gitnexus-lbug-checkpoint-lifecycle/lbug');
events.length = 0;
await adapter.closeLbug();
expect(events).toEqual([
'checkpoint:query',
'checkpoint:getAll',
'checkpoint:close',
'conn:close',
'db:close',
]);
});
it('closes normal query results after reading rows', async () => {
vi.resetModules();
const events: string[] = [];
const queryResult = {
getAll: vi.fn(async () => {
events.push('query:getAll');
return [{ id: 'file:a' }];
}),
close: vi.fn(() => {
events.push('query:close');
}),
};
const genericResult = {
getAll: vi.fn(async () => []),
close: vi.fn(),
};
const conn = {
query: vi.fn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
events.push('query:run');
return queryResult;
}
return genericResult;
}),
close: vi.fn(async () => {}),
};
const db = {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-query-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug('/tmp/gitnexus-lbug-query-lifecycle/lbug');
events.length = 0;
await expect(adapter.executeQuery('MATCH (n:File) RETURN n.id AS id')).resolves.toEqual([
{ id: 'file:a' },
]);
expect(events).toEqual(['query:run', 'query:getAll', 'query:close']);
await adapter.closeLbug();
});
it('treats synchronous query result close errors as best-effort cleanup', async () => {
vi.resetModules();
const queryResult = {
getAll: vi.fn(async () => [{ id: 'file:a' }]),
close: vi.fn(() => {
throw new Error('close failed');
}),
};
const genericResult = {
getAll: vi.fn(async () => []),
close: vi.fn(),
};
const conn = {
query: vi.fn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
return queryResult;
}
return genericResult;
}),
close: vi.fn(async () => {}),
};
const db = {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-sync-close-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug('/tmp/gitnexus-lbug-sync-close-lifecycle/lbug');
await expect(adapter.executeQuery('MATCH (n:File) RETURN n.id AS id')).resolves.toEqual([
{ id: 'file:a' },
]);
expect(queryResult.close).toHaveBeenCalledOnce();
await adapter.closeLbug();
});
it('closes later query results when an earlier array result fails to read', async () => {
vi.resetModules();
const events: string[] = [];
const firstResult = {
getAll: vi.fn(async () => {
events.push('first:getAll');
throw new Error('read failed');
}),
close: vi.fn(() => {
events.push('first:close');
}),
};
const secondResult = {
getAll: vi.fn(async () => {
events.push('second:getAll');
return [];
}),
close: vi.fn(() => {
events.push('second:close');
}),
};
const genericResult = {
getAll: vi.fn(async () => []),
close: vi.fn(),
};
const conn = {
query: vi.fn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
return [firstResult, secondResult];
}
return genericResult;
}),
close: vi.fn(async () => {}),
};
const db = {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-array-error-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug('/tmp/gitnexus-lbug-array-error-lifecycle/lbug');
await expect(adapter.executeQuery('MATCH (n:File) RETURN n.id AS id')).rejects.toThrow(
'read failed',
);
expect(events).toEqual(['first:getAll', 'first:close', 'second:getAll', 'second:close']);
await adapter.closeLbug();
});
it('closes non-first stream query results when LadybugDB returns an array', async () => {
vi.resetModules();
const events: string[] = [];
const firstResult = {
hasNext: vi
.fn()
.mockImplementationOnce(() => {
events.push('first:hasNext:true');
return true;
})
.mockImplementationOnce(() => {
events.push('first:hasNext:false');
return false;
}),
getNext: vi.fn(async () => {
events.push('first:getNext');
return { id: 'file:a' };
}),
getAll: vi.fn(async () => {
events.push('first:getAll');
return [];
}),
close: vi.fn(() => {
events.push('first:close');
}),
};
const secondResult = {
getAll: vi.fn(async () => {
events.push('second:getAll');
return [];
}),
close: vi.fn(() => {
events.push('second:close');
}),
};
const genericResult = {
getAll: vi.fn(async () => []),
close: vi.fn(),
};
const conn = {
query: vi.fn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
events.push('stream:query');
return [firstResult, secondResult];
}
return genericResult;
}),
close: vi.fn(async () => {}),
};
const db = {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-stream-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug('/tmp/gitnexus-lbug-stream-lifecycle/lbug');
const rows: unknown[] = [];
events.length = 0;
await expect(
adapter.streamQuery('MATCH (n:File) RETURN n.id AS id', (row) => {
rows.push(row);
}),
).resolves.toBe(1);
expect(rows).toEqual([{ id: 'file:a' }]);
expect(events).toEqual([
'stream:query',
'first:hasNext:true',
'first:getNext',
'first:hasNext:false',
'first:getAll',
'first:close',
'second:getAll',
'second:close',
]);
await adapter.closeLbug();
});
it('drains stream query results when row handling fails before the result is exhausted', async () => {
vi.resetModules();
const events: string[] = [];
const queryResult = {
hasNext: vi.fn(() => {
events.push('stream:hasNext');
return true;
}),
getNext: vi.fn(async () => {
events.push('stream:getNext');
return { id: 'file:a' };
}),
getAll: vi.fn(async () => {
events.push('stream:getAll');
return [{ id: 'file:b' }];
}),
close: vi.fn(() => {
events.push('stream:close');
}),
};
const genericResult = {
getAll: vi.fn(async () => []),
close: vi.fn(),
};
const conn = {
query: vi.fn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
events.push('stream:query');
return queryResult;
}
return genericResult;
}),
close: vi.fn(async () => {}),
};
const db = {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-stream-error-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug('/tmp/gitnexus-lbug-stream-error-lifecycle/lbug');
await expect(
adapter.streamQuery('MATCH (n:File) RETURN n.id AS id', () => {
throw new Error('client disconnected');
}),
).rejects.toThrow('client disconnected');
expect(events).toEqual([
'stream:query',
'stream:hasNext',
'stream:getNext',
'stream:getAll',
'stream:close',
]);
await adapter.closeLbug();
});
});