Skip to content

Commit 5c6b770

Browse files
sjsyrekclaude
andauthored
fix(cache): stop quarantining a healthy database when the native module fails to load (#95)
A native-module load failure (ERR_DLOPEN_FAILED after a Node major upgrade, ERR_UNKNOWN_BUILTIN_MODULE on runtimes without node:sqlite, NODE_MODULE_VERSION mismatch) was caught by the CacheService constructor's corruption handler, which renamed the user's healthy cache.db to cache.db.corrupt-<timestamp> and recreated an empty database. Reproduced with real data loss: a 1MB, 2646-entry cache that passed integrity_check was quarantined when node_modules built for Node 24 ran under Node 22. - classify load failures in CacheService and rethrow without touching the database or its -wal/-shm sidecars; genuine corruption still gets the rename-aside recovery - load the cache behind a warn-once latch (cli/cache-loader.ts): on failure, warn once with an actionable hint and return undefined so translate/write run cacheless with exit code 0 - make the optional cache in TranslationService/WriteService real: no cache means cacheless, instead of falling back to CacheService.getInstance(), which re-triggered the same crash - deepl cache subcommands, which cannot run cacheless, now fail with a ConfigError suggesting a reinstall or matching Node version 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8233543 commit 5c6b770

16 files changed

Lines changed: 601 additions & 37 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
### Fixed
1919

20+
- **cache**: A cache backend that fails to load (e.g. `better-sqlite3` ABI mismatch after a Node major upgrade, `ERR_DLOPEN_FAILED`) is no longer misclassified as database corruption. Previously the constructor's catch-all renamed the user's healthy `cache.db` to `cache.db.corrupt-<timestamp>` and recreated an empty database — verified to quarantine a 2,646-entry cache that passed `integrity_check`. Native-module load failures now leave the database and its `-wal`/`-shm` sidecars untouched; genuine corruption still triggers the rename-aside recovery. `deepl translate` and `deepl write` degrade to running without a cache (single warning per process, exit code 0) instead of crashing, since the cache backend is loaded lazily behind a warn-once latch; `deepl cache …` subcommands, which cannot run cacheless, fail with an actionable error suggesting a reinstall or matching Node version.
2021
- **sync**: `deepl sync --frozen` now reports an accurate key count when drift is caused by a newly-added target locale. Previously the message read `Sync drift detected: 0 new, 0 stale keys.` because the frozen branch in `processBucket` short-circuited before promoting current-status keys missing a target-locale translation into `newKeys` — even though `--dry-run` against the same state correctly reported the backfill count. The drift exit code (10) is unchanged. The drift message now also surfaces `deletedKeys` and only mentions nonzero categories, mirroring the success-path summary format.
2122

2223
### Security

‎docs/TROUBLESHOOTING.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,16 +417,20 @@ rm ~/.cache/deepl-cli/cache.db # or ~/.deepl-cli/cache.db for legacy installat
417417
deepl cache enable
418418
```
419419

420-
### "Cache database corrupted" with NODE_MODULE_VERSION mismatch
420+
### "Translation cache backend failed to load" with NODE_MODULE_VERSION mismatch
421421

422422
**Cause:** The `better-sqlite3` native addon was compiled for a different Node.js version than the one currently running. This happens when you switch Node.js versions (e.g., via `nvm use`, `fnm`, or a system upgrade) without rebuilding native modules.
423423

424+
Translation and write commands keep working with caching disabled for the run; your cache database is not modified. `deepl cache` subcommands fail until the backend loads again.
425+
424426
**Solution:**
425427

426428
```bash
427429
npm rebuild better-sqlite3
428430
```
429431

432+
or reinstall the CLI / switch back to the Node.js version it was installed with.
433+
430434
---
431435

432436
## Document Translation Issues

‎src/cli/cache-loader.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Lazy cache-service loader for the CLI entry point.
3+
* The cache backend (a native module today, node:sqlite tomorrow) can
4+
* fail to load on runtimes it wasn't built for. Commands that merely
5+
* benefit from a cache must degrade to running without one instead of
6+
* crashing, with a single warning per process.
7+
*/
8+
9+
import type { CacheService, CacheServiceOptions } from '../storage/cache.js';
10+
import { Logger } from '../utils/logger.js';
11+
import { errorMessage } from '../utils/error-message.js';
12+
import { isNativeModuleLoadError } from '../utils/native-module-error.js';
13+
14+
type CacheModule = Pick<typeof import('../storage/cache.js'), 'CacheService'>;
15+
16+
export function createCacheServiceGetter(
17+
getOptions: () => CacheServiceOptions,
18+
importCacheModule: () => Promise<CacheModule> = () => import('../storage/cache.js'),
19+
): () => Promise<CacheService | undefined> {
20+
let instance: CacheService | undefined;
21+
let unavailable = false;
22+
23+
return async (): Promise<CacheService | undefined> => {
24+
if (instance || unavailable) {
25+
return instance;
26+
}
27+
try {
28+
const { CacheService: CacheSvc } = await importCacheModule();
29+
instance = CacheSvc.getInstance(getOptions());
30+
} catch (error) {
31+
unavailable = true;
32+
const detail = errorMessage(error);
33+
if (isNativeModuleLoadError(error)) {
34+
Logger.warn(
35+
`Translation cache backend failed to load (${detail}). ` +
36+
'Your cache database has not been modified. Caching is disabled for this run. ' +
37+
'Reinstall the CLI, or run it with the Node.js version it was installed with, to restore caching.',
38+
);
39+
} else {
40+
Logger.warn(
41+
`Translation cache is unavailable (${detail}). Caching is disabled for this run.`,
42+
);
43+
}
44+
}
45+
return instance;
46+
};
47+
}

‎src/cli/commands/register-cache.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,29 @@ import chalk from 'chalk';
33
import type { ConfigService } from '../../storage/config.js';
44
import type { CacheService } from '../../storage/cache.js';
55
import { Logger } from '../../utils/logger.js';
6+
import { ConfigError } from '../../utils/errors.js';
67

78
export function registerCache(
89
program: Command,
910
deps: {
1011
getConfigService: () => ConfigService;
11-
getCacheService: () => Promise<CacheService>;
12+
getCacheService: () => Promise<CacheService | undefined>;
1213
handleError: (error: unknown) => never;
1314
},
1415
): void {
1516
const { getConfigService, getCacheService, handleError } = deps;
1617

18+
async function requireCacheService(): Promise<CacheService> {
19+
const cacheService = await getCacheService();
20+
if (!cacheService) {
21+
throw new ConfigError(
22+
'Cache backend is unavailable, so cache commands cannot run.',
23+
'Reinstall the CLI, or run it with the Node.js version it was installed with.',
24+
);
25+
}
26+
return cacheService;
27+
}
28+
1729
program
1830
.command('cache')
1931
.description('Manage translation cache')
@@ -32,7 +44,7 @@ Examples:
3244
.action(async (options: { format?: string }) => {
3345
try {
3446
const { CacheCommand } = await import('./cache.js');
35-
const cacheCommand = new CacheCommand(await getCacheService(), getConfigService());
47+
const cacheCommand = new CacheCommand(await requireCacheService(), getConfigService());
3648
const stats = await cacheCommand.stats();
3749
if (options.format === 'json') {
3850
Logger.output(JSON.stringify(stats, null, 2));
@@ -61,7 +73,7 @@ Examples:
6173
try {
6274
if (options.dryRun) {
6375
const { CacheCommand } = await import('./cache.js');
64-
const cacheCommand = new CacheCommand(await getCacheService(), getConfigService());
76+
const cacheCommand = new CacheCommand(await requireCacheService(), getConfigService());
6577
const stats = await cacheCommand.stats();
6678
const totalSizeMB = (stats.totalSize / (1024 * 1024)).toFixed(2);
6779
const lines = [
@@ -83,7 +95,7 @@ Examples:
8395
}
8496

8597
const { CacheCommand } = await import('./cache.js');
86-
const cacheCommand = new CacheCommand(await getCacheService(), getConfigService());
98+
const cacheCommand = new CacheCommand(await requireCacheService(), getConfigService());
8799
await cacheCommand.clear();
88100
Logger.success(chalk.green('\u2713 Cache cleared successfully'));
89101
} catch (error) {
@@ -106,7 +118,7 @@ Examples:
106118
}
107119

108120
const { CacheCommand } = await import('./cache.js');
109-
const cacheCommand = new CacheCommand(await getCacheService(), getConfigService());
121+
const cacheCommand = new CacheCommand(await requireCacheService(), getConfigService());
110122
await cacheCommand.enable(maxSizeBytes);
111123
Logger.success(chalk.green('\u2713 Cache enabled'));
112124

@@ -126,7 +138,7 @@ Examples:
126138
.action(async () => {
127139
try {
128140
const { CacheCommand } = await import('./cache.js');
129-
const cacheCommand = new CacheCommand(await getCacheService(), getConfigService());
141+
const cacheCommand = new CacheCommand(await requireCacheService(), getConfigService());
130142
await cacheCommand.disable();
131143
Logger.success(chalk.green('\u2713 Cache disabled'));
132144
} catch (error) {

‎src/cli/commands/service-factory.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface ServiceDeps {
2222
createDeepLClient: CreateDeepLClient;
2323
getApiKeyAndOptions: GetApiKeyAndOptions;
2424
getConfigService: () => ConfigService;
25-
getCacheService: () => Promise<CacheService>;
25+
getCacheService: () => Promise<CacheService | undefined>;
2626
handleError: (error: unknown) => never;
2727
}
2828

‎src/cli/index.ts‎

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { readFileSync } from 'fs';
1111
import { fileURLToPath } from 'url';
1212
import { dirname, join, resolve, isAbsolute, extname } from 'path';
1313
import { ConfigService } from '../storage/config.js';
14-
import type { CacheService } from '../storage/cache.js';
14+
import { createCacheServiceGetter } from './cache-loader.js';
1515
import { resolvePaths } from '../utils/paths.js';
1616
import type { DeepLClient } from '../api/deepl-client.js';
1717
import { Logger } from '../utils/logger.js';
@@ -57,22 +57,17 @@ const paths = resolvePaths();
5757

5858
// Create config service - can be overridden by --config flag
5959
let configService = new ConfigService(paths.configFile);
60-
let cacheService: CacheService | null = null;
61-
62-
async function getCacheService(): Promise<CacheService> {
63-
if (!cacheService) {
64-
const { CacheService: CacheSvc } = await import('../storage/cache.js');
65-
const configTtl = configService.getValue<number>('cache.ttl');
66-
const configMaxSize = configService.getValue<number>('cache.maxSize');
67-
cacheService = CacheSvc.getInstance({
68-
dbPath: paths.cacheFile,
69-
// Config TTL is in seconds, CacheService expects milliseconds
70-
ttl: configTtl !== undefined ? configTtl * 1000 : undefined,
71-
maxSize: configMaxSize,
72-
});
73-
}
74-
return cacheService;
75-
}
60+
61+
const getCacheService = createCacheServiceGetter(() => {
62+
const configTtl = configService.getValue<number>('cache.ttl');
63+
const configMaxSize = configService.getValue<number>('cache.maxSize');
64+
return {
65+
dbPath: paths.cacheFile,
66+
// Config TTL is in seconds, CacheService expects milliseconds
67+
ttl: configTtl !== undefined ? configTtl * 1000 : undefined,
68+
maxSize: configMaxSize,
69+
};
70+
});
7671

7772
/**
7873
* Handle error and exit with appropriate exit code

‎src/services/translation.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as crypto from 'crypto';
77
import { DeepLClient, TranslationResult, isTranslationResult, UsageInfo, LanguageInfo } from '../api/deepl-client.js';
88
import { ConfigService } from '../storage/config.js';
9-
import { CacheService } from '../storage/cache.js';
9+
import type { CacheService } from '../storage/cache.js';
1010
import { TranslationOptions, Language, TranslationMemory } from '../types/index.js';
1111
import { Logger } from '../utils/logger.js';
1212
import { mapWithConcurrency, MULTI_TARGET_CONCURRENCY } from '../utils/concurrency.js';
@@ -40,14 +40,16 @@ export const TRANSLATE_BATCH_SIZE = 50; // DeepL API max texts per request
4040
export class TranslationService {
4141
private client: DeepLClient;
4242
private config: ConfigService;
43-
private cache: CacheService;
43+
// No cache means "run cacheless" — the CLI passes undefined when the
44+
// cache backend is unavailable (see cli/cache-loader.ts).
45+
private cache?: CacheService;
4446
private languageCache: Map<'source' | 'target', { data: LanguageInfo[]; timestamp: number }> = new Map();
4547
private readonly LANGUAGE_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
4648

4749
constructor(client: DeepLClient, config: ConfigService, cache?: CacheService) {
4850
this.client = client;
4951
this.config = config;
50-
this.cache = cache ?? CacheService.getInstance();
52+
this.cache = cache;
5153
}
5254

5355
/**
@@ -112,7 +114,7 @@ export class TranslationService {
112114
const cacheKey = this.generateCacheKey(processedText, translationOptions);
113115

114116
if (shouldUseCache) {
115-
const cachedResult = this.cache.get(cacheKey, isTranslationResult);
117+
const cachedResult = this.cache?.get(cacheKey, isTranslationResult);
116118
if (cachedResult) {
117119
Logger.verbose('[verbose] Cache hit');
118120
return {
@@ -131,7 +133,7 @@ export class TranslationService {
131133

132134
// Store in cache
133135
if (shouldUseCache) {
134-
this.cache.set(cacheKey, result);
136+
this.cache?.set(cacheKey, result);
135137
}
136138

137139
return {
@@ -198,7 +200,7 @@ export class TranslationService {
198200
const cacheKey = this.generateCacheKey(text, translationOptions);
199201

200202
if (cacheEnabled) {
201-
const cachedResult = this.cache.get(cacheKey, isTranslationResult);
203+
const cachedResult = this.cache?.get(cacheKey, isTranslationResult);
202204
if (cachedResult) {
203205
results[i] = cachedResult;
204206
continue;
@@ -281,7 +283,7 @@ export class TranslationService {
281283
// Cache the result (only once per unique text)
282284
if (cacheEnabled) {
283285
const cacheKey = this.generateCacheKey(text, translationOptions);
284-
this.cache.set(cacheKey, result);
286+
this.cache?.set(cacheKey, result);
285287
}
286288
}
287289
}

‎src/services/write.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as crypto from 'crypto';
77
import { DeepLClient } from '../api/deepl-client.js';
88
import { ConfigService } from '../storage/config.js';
9-
import { CacheService } from '../storage/cache.js';
9+
import type { CacheService } from '../storage/cache.js';
1010
import { WriteOptions, WriteImprovement, isWriteImprovementArray } from '../types/index.js';
1111
import { Logger } from '../utils/logger.js';
1212
import { ValidationError, ConfigError } from '../utils/errors.js';
@@ -18,7 +18,9 @@ export interface WriteServiceOptions {
1818
export class WriteService {
1919
private client: DeepLClient;
2020
private config: ConfigService;
21-
private cache: CacheService;
21+
// No cache means "run cacheless" — the CLI passes undefined when the
22+
// cache backend is unavailable (see cli/cache-loader.ts).
23+
private cache?: CacheService;
2224

2325
constructor(client: DeepLClient, config: ConfigService, cache?: CacheService) {
2426
if (!client) {
@@ -31,7 +33,7 @@ export class WriteService {
3133

3234
this.client = client;
3335
this.config = config;
34-
this.cache = cache ?? CacheService.getInstance();
36+
this.cache = cache;
3537
}
3638

3739
/**
@@ -62,7 +64,7 @@ export class WriteService {
6264
const cacheKey = this.generateCacheKey(text, options);
6365

6466
if (shouldUseCache) {
65-
const cachedResult = this.cache.get(cacheKey, isWriteImprovementArray);
67+
const cachedResult = this.cache?.get(cacheKey, isWriteImprovementArray);
6668
if (cachedResult) {
6769
Logger.verbose('[verbose] Cache hit');
6870
return cachedResult;
@@ -73,7 +75,7 @@ export class WriteService {
7375
const improvements = await this.client.improveText(text, options);
7476

7577
if (shouldUseCache) {
76-
this.cache.set(cacheKey, improvements);
78+
this.cache?.set(cacheKey, improvements);
7779
}
7880

7981
return improvements;

‎src/storage/cache.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { resolvePaths } from '../utils/paths.js';
1010
import { ConfigError } from '../utils/errors.js';
1111
import { Logger } from '../utils/logger.js';
1212
import { errorMessage } from '../utils/error-message.js';
13+
import { isNativeModuleLoadError } from '../utils/native-module-error.js';
1314

1415
export interface CacheServiceOptions {
1516
dbPath?: string;
@@ -69,6 +70,12 @@ export class CacheService {
6970
try {
7071
this.openDatabase(dbPath);
7172
} catch (error) {
73+
// A backend that cannot load (ABI mismatch after a Node upgrade,
74+
// missing binding) is not corruption: the database on disk is
75+
// healthy, so renaming it aside would throw away a warm cache.
76+
if (isNativeModuleLoadError(error)) {
77+
throw error;
78+
}
7279
// Corrupted DB: rename-aside rather than unlink, so the user keeps
7380
// 30 days of cache history (and a forensic artifact) instead of
7481
// losing both silently. Suffix with a timestamp so repeated

‎src/utils/native-module-error.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Classifies errors thrown while loading the cache's storage backend.
3+
* These indicate the module itself cannot load (ABI mismatch after a
4+
* Node upgrade, missing binding, runtime without node:sqlite) — the
5+
* database file on disk is healthy and must not be touched.
6+
*/
7+
8+
const NATIVE_LOAD_ERROR_CODES = new Set([
9+
'ERR_DLOPEN_FAILED',
10+
'ERR_UNKNOWN_BUILTIN_MODULE',
11+
'MODULE_NOT_FOUND',
12+
'ERR_MODULE_NOT_FOUND',
13+
]);
14+
15+
export function isNativeModuleLoadError(error: unknown): boolean {
16+
if (!(error instanceof Error)) {
17+
return false;
18+
}
19+
const code = (error as NodeJS.ErrnoException).code;
20+
if (code && NATIVE_LOAD_ERROR_CODES.has(code)) {
21+
return true;
22+
}
23+
return error.message.includes('NODE_MODULE_VERSION');
24+
}

0 commit comments

Comments
 (0)