GitNexus/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts
Gergő Magyar cdbdf219dc
Some checks are pending
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(lbug): reclaim missing-shadow WAL quarantine files on write-path init (#2638)
2026-07-22 21:30:52 +01:00

867 lines
28 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 () => {}),
}));
/** Mock prepared statement shape for executePrepared/prepare+execute paths. */
const makePreparedStatement = (sql: string) => ({
sql,
isSuccess: () => true,
getErrorMessage: () => '',
});
/** Mock connection supporting both query() and prepare/execute() call paths. */
const makeConn = (runQuery: (sql: string) => Promise<unknown>) => {
const query = vi.fn(runQuery);
return {
query,
prepare: vi.fn(async (sql: string) => makePreparedStatement(sql)),
execute: vi.fn(async (statement: { sql: string }) => query(statement.sql)),
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(),
readdir: vi.fn(async () => []),
},
}));
};
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 = makeConn(async () => queryResult);
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(),
readdir: vi.fn(async () => []),
},
}));
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 = makeConn(async () => queryResult);
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(),
readdir: vi.fn(async () => []),
},
}));
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 = makeConn(async () => queryResult);
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(),
readdir: vi.fn(async () => []),
},
}));
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(),
readdir: vi.fn(async () => []),
},
}));
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 = makeConn(async () => queryResult);
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(),
readdir: vi.fn(async () => []),
},
}));
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 = makeConn(async () => queryResult);
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(),
readdir: vi.fn(async () => []),
},
}));
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 = makeConn(async (sql: string) => {
if (sql === 'CHECKPOINT') {
events.push('checkpoint:query');
return checkpointResult;
}
return genericResult;
});
conn.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 = makeConn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
events.push('query:run');
return queryResult;
}
return genericResult;
});
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 = makeConn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
return queryResult;
}
return genericResult;
});
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 = makeConn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
return [firstResult, secondResult];
}
return genericResult;
});
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 = makeConn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
events.push('stream:query');
return [firstResult, secondResult];
}
return genericResult;
});
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 = makeConn(async (sql: string) => {
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
events.push('stream:query');
return queryResult;
}
return genericResult;
});
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();
});
});