mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
Merge branch 'main' into copilot/migrate-rust-to-scope-resolution
This commit is contained in:
commit
e4f04e964c
3 changed files with 996 additions and 8 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import fs from 'fs/promises';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { createReadStream, createWriteStream, constants as fsConstants } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import { once } from 'events';
|
||||
import { finished } from 'stream/promises';
|
||||
|
|
@ -201,6 +201,163 @@ export const isReadOnlyDbError = (err: unknown): boolean => {
|
|||
return /read-only database/i.test(msg);
|
||||
};
|
||||
|
||||
const isMissingFileError = (err: unknown): boolean => {
|
||||
const errno = err as NodeJS.ErrnoException;
|
||||
return errno?.code === 'ENOENT';
|
||||
};
|
||||
|
||||
const extractErrnoCode = (err: unknown): string | undefined => {
|
||||
const errno = err as NodeJS.ErrnoException;
|
||||
return errno?.code;
|
||||
};
|
||||
|
||||
const MAX_LOGGED_ERROR_MESSAGE_LENGTH = 160;
|
||||
|
||||
const summarizeError = (err: unknown): string =>
|
||||
(err instanceof Error ? err.message : String(err)).slice(0, MAX_LOGGED_ERROR_MESSAGE_LENGTH);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-process init lock
|
||||
//
|
||||
// Prevents a TOCTOU race in orphan sidecar cleanup: between checking that
|
||||
// the main DB file is missing and unlinking sidecars, another process could
|
||||
// create a fresh DB. The lock file (`${dbPath}.init.lock`) is created with
|
||||
// O_CREAT | O_EXCL (atomic create-or-fail) and contains the owning PID +
|
||||
// timestamp so stale locks from crashed processes can be reclaimed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum age (ms) before an init lock is considered stale. */
|
||||
const INIT_LOCK_STALE_MS = 30_000;
|
||||
/** Maximum attempts to acquire the init lock before giving up. */
|
||||
const INIT_LOCK_MAX_ATTEMPTS = 6;
|
||||
/** Delay between lock-acquisition retries (ms). */
|
||||
const INIT_LOCK_RETRY_DELAY_MS = 500;
|
||||
|
||||
const initLockPath = (dbPath: string): string => `${dbPath}.init.lock`;
|
||||
|
||||
/**
|
||||
* Returns true when the process identified by `pid` is still running.
|
||||
* Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe) —
|
||||
* it throws ESRCH when the process does not exist.
|
||||
*/
|
||||
const isProcessAlive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to break a stale lock whose owning process has exited.
|
||||
* Returns `true` if the stale lock was removed (caller should retry acquire).
|
||||
* Returns `false` if the lock is still valid (another live process owns it).
|
||||
*/
|
||||
const tryBreakStaleLock = async (lockPath: string): Promise<boolean> => {
|
||||
try {
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content) as { pid?: number; ts?: number };
|
||||
|
||||
// If the owning process is still alive AND the lock is not stale, don't break.
|
||||
if (typeof parsed.pid === 'number' && isProcessAlive(parsed.pid)) {
|
||||
// Even a live process's lock can be stale if it's been held too long
|
||||
// (e.g. the process is hung). Check the timestamp.
|
||||
if (typeof parsed.ts === 'number' && Date.now() - parsed.ts < INIT_LOCK_STALE_MS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// PID is gone or lock exceeded INIT_LOCK_STALE_MS — reclaim it.
|
||||
await fs.unlink(lockPath);
|
||||
logger.warn(
|
||||
`GitNexus: removed stale init lock (pid=${parsed.pid ?? '?'}, age=${typeof parsed.ts === 'number' ? `${Date.now() - parsed.ts}ms` : '?'})`,
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Lock file disappeared between our read and unlink, or is unreadable.
|
||||
// Either way, let the caller retry the acquire.
|
||||
if (isMissingFileError(err)) return true;
|
||||
// Permission error or corrupt content — log and let caller retry.
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: unable to inspect init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Acquire a cross-process init lock for `dbPath`.
|
||||
* Uses `O_CREAT | O_EXCL` for atomic create-or-fail semantics.
|
||||
*
|
||||
* Returns a release function that removes the lock file. The release
|
||||
* function is idempotent and safe to call even if the lock was already
|
||||
* cleaned up externally.
|
||||
*
|
||||
* Throws if the lock cannot be acquired after `INIT_LOCK_MAX_ATTEMPTS`.
|
||||
*/
|
||||
export const acquireInitLock = async (dbPath: string): Promise<() => Promise<void>> => {
|
||||
const lockPath = initLockPath(dbPath);
|
||||
const payload = JSON.stringify({ pid: process.pid, ts: Date.now() });
|
||||
|
||||
// Ensure the parent directory exists before creating the lock file.
|
||||
// On a fresh repo the `.gitnexus/` directory may not exist yet, and
|
||||
// fs.open with O_CREAT | O_EXCL would fail with ENOENT.
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||
|
||||
for (let attempt = 1; attempt <= INIT_LOCK_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const handle = await fs.open(
|
||||
lockPath,
|
||||
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
||||
);
|
||||
await handle.writeFile(payload);
|
||||
await handle.close();
|
||||
|
||||
// Return the idempotent release function
|
||||
return async () => {
|
||||
try {
|
||||
await fs.unlink(lockPath);
|
||||
} catch (err) {
|
||||
if (!isMissingFileError(err)) {
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: failed to release init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'EEXIST') {
|
||||
throw err; // Unexpected error — propagate immediately
|
||||
}
|
||||
|
||||
// Lock file exists — check if it's stale
|
||||
const broken = await tryBreakStaleLock(lockPath);
|
||||
if (broken && attempt < INIT_LOCK_MAX_ATTEMPTS) {
|
||||
continue; // Stale lock removed — retry immediately
|
||||
}
|
||||
|
||||
if (attempt === INIT_LOCK_MAX_ATTEMPTS) {
|
||||
throw new Error(
|
||||
`GitNexus: unable to acquire init lock after ${INIT_LOCK_MAX_ATTEMPTS} attempts — ` +
|
||||
`another gitnexus process may be initializing the same database (${lockPath})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Live process holds the lock — wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, INIT_LOCK_RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable — loop always throws or returns
|
||||
throw new Error('GitNexus: init lock acquisition failed unexpectedly');
|
||||
};
|
||||
|
||||
/** Exported for testing — returns the lock file path for a given dbPath. */
|
||||
export const _initLockPathForTest = initLockPath;
|
||||
|
||||
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const previous = sessionLock;
|
||||
let release: (() => void) | null = null;
|
||||
|
|
@ -364,17 +521,64 @@ const doInitLbug = async (dbPath: string) => {
|
|||
await fs.rm(dbPath, { recursive: true, force: true });
|
||||
}
|
||||
// If it's a file, assume it's an existing LadybugDB database - LadybugDB will open it
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!isMissingFileError(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Path doesn't exist, which is what LadybugDB wants for a new database
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-process critical section: acquire init lock, clean orphan sidecars,
|
||||
// and open the database. The lock prevents a TOCTOU race where another
|
||||
// process could create a fresh DB between our access() check and the
|
||||
// unlink() of stale sidecars.
|
||||
// ---------------------------------------------------------------------------
|
||||
const releaseInitLock = await acquireInitLock(dbPath);
|
||||
try {
|
||||
// Crash-recovery cleanup: if the main DB file is missing, stale sidecars
|
||||
// from an interrupted run can block fresh opens indefinitely.
|
||||
try {
|
||||
await fs.access(dbPath);
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
// `.shadow` is documented by LadybugDB checkpointing and `.wal.checkpoint`
|
||||
// was observed in the #1618 crash loop that motivated this recovery path.
|
||||
const orphanSidecars = [`${dbPath}.shadow`, `${dbPath}.wal.checkpoint`];
|
||||
for (const sidecar of orphanSidecars) {
|
||||
try {
|
||||
await fs.unlink(sidecar);
|
||||
logger.warn(
|
||||
`GitNexus: removed orphan sidecar ${path.basename(sidecar)} (no main DB file present)`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
continue;
|
||||
}
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: failed to remove orphan sidecar ${path.basename(sidecar)} (${code ?? 'UNKNOWN'}) while main DB file is missing; LadybugDB open may still fail: ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: unable to verify main DB file before orphan sidecar cleanup (${code ?? 'UNKNOWN'}); skipping cleanup: ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const opened = await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
|
||||
const opened = await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
} finally {
|
||||
await releaseInitLock();
|
||||
}
|
||||
|
||||
for (const schemaQuery of SCHEMA_QUERIES) {
|
||||
try {
|
||||
|
|
|
|||
330
gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts
Normal file
330
gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
/**
|
||||
* Integration test: orphan sidecar recovery in doInitLbug.
|
||||
*
|
||||
* Exercises the real `initLbug` → `doInitLbug` path against a native
|
||||
* LadybugDB instance. Creates actual orphan `.shadow` and
|
||||
* `.wal.checkpoint` files on disk (without a main DB file) and confirms
|
||||
* that `initLbug` cleans them up and opens a fresh database successfully.
|
||||
*
|
||||
* This complements the unit-level mocked coverage in
|
||||
* `lbug-checkpoint-lifecycle.test.ts` with a real-filesystem,
|
||||
* real-LadybugDB integration proof required by DoD §2.7.
|
||||
*/
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
|
||||
* does not release the underlying file lock until the process exits, so any
|
||||
* `closeLbug()` followed by `initLbug(samePath)` in the same process raises
|
||||
* Win32 Error 33. Skip reopen-dependent tests on Windows.
|
||||
*/
|
||||
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
|
||||
|
||||
describe('orphan sidecar recovery — native integration', () => {
|
||||
itLbugReopen(
|
||||
'initLbug recovers when both .shadow and .wal.checkpoint orphan sidecars are present without a main DB file',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const shadowPath = `${dbPath}.shadow`;
|
||||
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
|
||||
|
||||
try {
|
||||
// Simulate crash-recovery state: orphan sidecars without main DB file
|
||||
await fs.writeFile(shadowPath, 'stale-shadow-data');
|
||||
await fs.writeFile(walCheckpointPath, 'stale-wal-checkpoint-data');
|
||||
|
||||
// Confirm precondition: main DB file does NOT exist, sidecars DO
|
||||
await expect(fs.access(dbPath)).rejects.toThrow();
|
||||
await expect(fs.access(shadowPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(walCheckpointPath)).resolves.toBeUndefined();
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// initLbug should clean up orphan sidecars and open a fresh DB
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
// Verify the database is functional — execute a simple query
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS result');
|
||||
expect(rows).toEqual([{ result: 1 }]);
|
||||
|
||||
// Verify orphan sidecars were removed
|
||||
await expect(fs.access(shadowPath)).rejects.toThrow();
|
||||
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
itLbugReopen(
|
||||
'initLbug recovers when only .shadow orphan sidecar is present (partial crash state)',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const shadowPath = `${dbPath}.shadow`;
|
||||
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
|
||||
|
||||
try {
|
||||
// Only .shadow present — partial crash state
|
||||
await fs.writeFile(shadowPath, 'stale-shadow-data');
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
const rows = await adapter.executeQuery('RETURN 42 AS answer');
|
||||
expect(rows).toEqual([{ answer: 42 }]);
|
||||
|
||||
// .shadow cleaned, .wal.checkpoint was never present
|
||||
await expect(fs.access(shadowPath)).rejects.toThrow();
|
||||
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
itLbugReopen('initLbug succeeds on a clean path with no orphan sidecars (baseline)', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS ok');
|
||||
expect(rows).toEqual([{ ok: 1 }]);
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen(
|
||||
'initLbug does not attempt orphan cleanup when the main DB file exists',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
// Place a marker file with a non-sidecar extension next to the DB path.
|
||||
// Our cleanup only targets `.shadow` and `.wal.checkpoint` and only when
|
||||
// the main DB is missing. We verify the DB opens normally and the marker
|
||||
// remains — proving that init did not perform broad sibling file cleanup.
|
||||
const markerPath = `${dbPath}.test-marker`;
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// Create a real DB file by initializing normally
|
||||
await adapter.initLbug(dbPath);
|
||||
await adapter.closeLbug();
|
||||
|
||||
// Plant marker file next to the existing DB
|
||||
await fs.writeFile(markerPath, 'should-survive');
|
||||
|
||||
// Re-init: main DB exists, so orphan cleanup should NOT fire
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS ok');
|
||||
expect(rows).toEqual([{ ok: 1 }]);
|
||||
|
||||
// Marker file survives — no broad cleanup happened
|
||||
const content = await fs.readFile(markerPath, 'utf-8');
|
||||
expect(content).toBe('should-survive');
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
// Clean up marker file — best-effort; may already be absent
|
||||
await fs.unlink(markerPath).catch(() => {
|
||||
/* test cleanup only */
|
||||
});
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init lock — cross-process ownership contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('init lock — single-process ownership contract', () => {
|
||||
itLbugReopen('acquireInitLock succeeds when parent directory does not exist yet', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
// Use a nested path whose parent directory does NOT exist
|
||||
const dbPath = path.join(tmp.dbPath, 'nonexistent-subdir', 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
// Precondition: parent directory must not exist
|
||||
await expect(fs.access(path.dirname(dbPath))).rejects.toThrow();
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Lock file should exist — parent dir was created automatically
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
|
||||
await release();
|
||||
|
||||
// Lock file gone after release
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('acquireInitLock creates and releases lock file atomically', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Lock file should exist while held
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
expect(typeof parsed.ts).toBe('number');
|
||||
|
||||
// Release the lock
|
||||
await release();
|
||||
|
||||
// Lock file should be gone after release
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('acquireInitLock blocks concurrent acquire from same process', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
const release1 = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Second acquire should fail because the lock is held by this (alive) process.
|
||||
// The lock retry budget is small enough that this completes quickly.
|
||||
await expect(adapter.acquireInitLock(dbPath)).rejects.toThrow(/unable to acquire init lock/);
|
||||
|
||||
await release1();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('acquireInitLock reclaims stale lock from dead process', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
// PID far above any realistic range — guaranteed not running on any OS.
|
||||
const DEAD_PROCESS_PID = 2_000_000_000;
|
||||
await fs.writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({ pid: DEAD_PROCESS_PID, ts: Date.now() - 60_000 }),
|
||||
);
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// Should break the stale lock and acquire successfully
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Verify we own the lock now
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
|
||||
await release();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('release is idempotent — calling twice does not throw', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
await release();
|
||||
// Second release — lock file already gone, should not throw
|
||||
await release();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen(
|
||||
'initLbug cleans up lock file after successful init with orphan sidecars',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
// Plant orphan sidecars
|
||||
await fs.writeFile(`${dbPath}.shadow`, 'stale-shadow');
|
||||
await fs.writeFile(`${dbPath}.wal.checkpoint`, 'stale-wal');
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
// Lock file should be released after init completes
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
|
||||
// DB should be functional
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS ok');
|
||||
expect(rows).toEqual([{ ok: 1 }]);
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
itLbugReopen('initLbug cleans up lock file even when DB open fails', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
// Use an invalid path that will cause LadybugDB to fail
|
||||
const dbPath = path.join(tmp.dbPath, 'nonexistent-subdir', 'deep', 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// initLbug should fail (parent dir structure may cause issues), but
|
||||
// we primarily care that the lock file is cleaned up even on failure.
|
||||
// Use a try/catch since the DB open may or may not fail depending
|
||||
// on how mkdir works.
|
||||
try {
|
||||
await adapter.initLbug(dbPath);
|
||||
await adapter.closeLbug();
|
||||
} catch {
|
||||
// Expected — DB open can fail for various reasons
|
||||
}
|
||||
|
||||
// Lock file should always be released, even on failure
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,461 @@
|
|||
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();
|
||||
|
||||
|
|
@ -43,6 +491,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
}),
|
||||
};
|
||||
|
||||
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 () => {}),
|
||||
|
|
@ -104,6 +553,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
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 () => {}),
|
||||
|
|
@ -158,6 +608,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
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 () => {}),
|
||||
|
|
@ -223,6 +674,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
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 () => {}),
|
||||
|
|
@ -303,6 +755,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
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 () => {}),
|
||||
|
|
@ -383,6 +836,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
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 () => {}),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue