diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c38c4..bcaa2dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING — cache/runtime**: The translation cache now uses Node's built-in `node:sqlite` module instead of the `better-sqlite3` native addon, and the CLI consequently requires **Node.js >= 24** (`engines.node` is now `>=24.0.0`). Node 24 is the first release where `node:sqlite` is non-experimental; Node 18/20 lack the module entirely and Node 22 would both warn on every invocation and downgrade the bundled SQLite (3.50.4 vs 3.53.1). Existing cache databases are read in place with no migration — the on-disk format is unchanged (SQLite 3.53.2-written files verified readable, WAL mode and `user_version` stamp included). **Migration**: upgrade to Node 24 (current LTS), e.g. `nvm install 24`; no other action is needed, and the cache keeps its contents. + - **ci**: `.nvmrc` now pins Node 24, matching the CI matrix. It still read `20`, so `nvm use` handed local developers a runtime that cannot load `node:sqlite` and does not satisfy `commander`'s `engines.node >=22.12.0`. - **build**: `npm run build` now runs a `clean` step first, removing `dist/` and `tsconfig.tsbuildinfo` before compiling. Without it, `tsc` leaves output for sources that no longer exist, so a file rename could ship stale artifacts — verified reproducible: planting `dist/sync/strings-client.js` and rebuilding left both files in `npm pack` output. Removing the build-info file is required too; deleting only `dist/` makes the incremental compiler report the output as up to date and emit nothing. - **tests**: Raised the timeout for the `watchAndSync` test block to 30s and replaced its fixed-round setup flush with a wait on an observable readiness signal. These tests intermittently exceeded the 10s default on CI runners — always as timeouts, never assertions — failing unrelated dependency PRs. Suite duration was measured to be flat across 250/25/5 flush rounds, so the historical 20 → 50 → 250 escalation could not have addressed it; the cost is runner CPU starvation (1.8s locally vs 10.8s observed on CI). The flush now also fails with the pending state rather than a bare timeout if setup genuinely stalls. - **deps**: `commander` 14.0.3 → 15.0.0. commander 15 is ESM-only (`"type": "module"`) and requires Node >=22.12.0, so it was unmergeable until the Node 24 baseline landed. Jest's `transformIgnorePatterns` allowlist gains `commander` so ts-jest transforms it under CommonJS test execution; without that, 28 suites fail to load with `SyntaxError: Cannot use import statement outside a module`. - **ci**: Test matrix, release workflow, and security workflow now target Node 24 (previously Node 20 and 22). Node 20 reached end-of-life in April 2026, and Node 24 is the current LTS. This aligns CI with the runtime the project is moving to; the `engines.node` range is unchanged in this entry and is bumped separately. +### Removed + +- **deps**: `better-sqlite3` and `@types/better-sqlite3`. The production dependency tree no longer contains any native addon, removing the entire class of ABI-mismatch failures (`ERR_DLOPEN_FAILED` / `NODE_MODULE_VERSION` errors after a Node major upgrade, e.g. via `brew upgrade node`), a 1.9 MB platform-specific binary, and the C++ compilation-toolchain requirement for installs from source. The cacheless-degradation safety net remains: a runtime whose `node:sqlite` is missing (Node < 22.5.0) warns once and runs uncached rather than crashing, and never touches the cache database. + ### Fixed - **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-` 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. diff --git a/CLAUDE.md b/CLAUDE.md index f2b032c..d534c44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Sync Engine (src/sync) Format Parsers (src/formats — 11 i18n formats) API Client (Translate, Write, Glossary, Document, Voice, StyleRules, Admin, TMS) ↓ -Storage (SQLite Cache, Config) + Static Data (src/data — language registry) +Storage (node:sqlite Cache, Config) + Static Data (src/data — language registry) ``` ### Configuration @@ -105,7 +105,7 @@ src/ ├── sync/ # Continuous localization engine (scan, diff, translate, write, lock) ├── formats/ # i18n file format parsers (JSON, YAML, PO, XLIFF, Android XML, etc.) ├── api/ # DeepL API client -├── storage/ # Data persistence (SQLite cache, config) +├── storage/ # Data persistence (node:sqlite cache, config) ├── data/ # Static data (language registry) ├── utils/ # Utility functions └── types/ # Type definitions diff --git a/package-lock.json b/package-lock.json index c175ca3..9699b41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "axios": "^1.7.9", - "better-sqlite3": "^12.11.1", "chalk": "^5.3.0", "chokidar": "^5.0.0", "cli-table3": "^0.6.5", @@ -33,7 +32,6 @@ "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.1.0", "@eslint/js": "^10.0.0", - "@types/better-sqlite3": "^7.6.8", "@types/jest": "^30.0.0", "@types/node": "^26.1.2", "@types/ws": "^8.18.1", @@ -53,7 +51,7 @@ "typescript-eslint": "^8.65.0" }, "engines": { - "node": ">=20.19.0", + "node": ">=24.0.0", "npm": ">=9.0.0" } }, @@ -2967,16 +2965,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3949,26 +3937,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.10.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", @@ -3982,40 +3950,6 @@ "node": ">=6.0.0" } }, - "node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/brace-expansion": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", @@ -4097,30 +4031,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4225,12 +4135,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4577,21 +4481,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4607,15 +4496,6 @@ } } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4642,15 +4522,6 @@ "node": ">=0.4.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -4718,15 +4589,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -5107,15 +4969,6 @@ "node": ">= 0.8.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", @@ -5274,12 +5127,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5383,12 +5230,6 @@ "node": ">= 6" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5512,12 +5353,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -5778,26 +5613,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5881,6 +5696,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -7601,18 +7417,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -7632,6 +7436,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7647,12 +7452,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7668,12 +7467,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -7719,18 +7512,6 @@ "node": ">=18.20.0 <20 || >=20.12.1" } }, - "node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7772,6 +7553,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8124,33 +7906,6 @@ "node": ">=8" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8212,16 +7967,6 @@ "node": ">=10" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8269,36 +8014,6 @@ ], "license": "MIT" }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-is-18": { "name": "react-is", "version": "18.3.1", @@ -8315,20 +8030,6 @@ "dev": true, "license": "MIT" }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -8458,26 +8159,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -8488,6 +8169,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8531,51 +8213,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8668,15 +8305,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -8889,34 +8517,6 @@ "url": "https://opencollective.com/synckit" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9205,18 +8805,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9391,12 +8979,6 @@ "punycode": "^2.1.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -9580,6 +9162,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index 3ebf534..464940b 100644 --- a/package.json +++ b/package.json @@ -55,12 +55,11 @@ "author": "DeepL SE", "license": "MIT", "engines": { - "node": ">=20.19.0", + "node": ">=24.0.0", "npm": ">=9.0.0" }, "dependencies": { "axios": "^1.7.9", - "better-sqlite3": "^12.11.1", "chalk": "^5.3.0", "chokidar": "^5.0.0", "cli-table3": "^0.6.5", @@ -80,7 +79,6 @@ "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.1.0", "@eslint/js": "^10.0.0", - "@types/better-sqlite3": "^7.6.8", "@types/jest": "^30.0.0", "@types/node": "^26.1.2", "@types/ws": "^8.18.1", diff --git a/src/cli/cache-loader.ts b/src/cli/cache-loader.ts index db8d996..0d58030 100644 --- a/src/cli/cache-loader.ts +++ b/src/cli/cache-loader.ts @@ -1,7 +1,7 @@ /** * Lazy cache-service loader for the CLI entry point. - * The cache backend (a native module today, node:sqlite tomorrow) can - * fail to load on runtimes it wasn't built for. Commands that merely + * The cache backend (node:sqlite) can fail to load on runtimes that + * predate it (Node < 22.5.0). Commands that merely * benefit from a cache must degrade to running without one instead of * crashing, with a single warning per process. */ diff --git a/src/storage/cache.ts b/src/storage/cache.ts index 492b515..3b0ff08 100644 --- a/src/storage/cache.ts +++ b/src/storage/cache.ts @@ -3,7 +3,7 @@ * SQLite-based translation cache with LRU eviction */ -import Database from 'better-sqlite3'; +import { DatabaseSync } from 'node:sqlite'; import * as fs from 'fs'; import * as path from 'path'; import { resolvePaths } from '../utils/paths.js'; @@ -49,7 +49,7 @@ const CACHE_SCHEMA_VERSION = 1; export class CacheService { private static instance: CacheService | null = null; private static handlersRegistered: boolean = false; - private db!: Database.Database; + private db!: DatabaseSync; private maxSize: number; private ttl: number; private enabled: boolean = true; @@ -70,8 +70,8 @@ export class CacheService { try { this.openDatabase(dbPath); } catch (error) { - // A backend that cannot load (ABI mismatch after a Node upgrade, - // missing binding) is not corruption: the database on disk is + // A backend that cannot load (node:sqlite missing on an older + // Node runtime) is not corruption: the database on disk is // healthy, so renaming it aside would throw away a warm cache. if (isNativeModuleLoadError(error)) { throw error; @@ -103,7 +103,7 @@ export class CacheService { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } - this.db = new Database(dbPath); + this.db = new DatabaseSync(dbPath); fs.chmodSync(dbPath, 0o600); this.initialize(); } @@ -140,13 +140,15 @@ export class CacheService { } private initialize(): void { - this.db.pragma('journal_mode = WAL'); + this.db.exec('PRAGMA journal_mode = WAL'); // Schema version check: pre-versioned DBs report user_version = 0 // and are upgrade-stamped in place (current schema is compatible). // Higher-than-current means the DB was written by a newer CLI // version than this one — refuse rather than risk data loss. - const userVersion = this.db.pragma('user_version', { simple: true }) as number; + const { user_version: userVersion } = this.db + .prepare('PRAGMA user_version') + .get() as { user_version: number }; if (userVersion > CACHE_SCHEMA_VERSION) { throw new ConfigError( `Cache DB schema version ${userVersion} is newer than this CLI supports (${CACHE_SCHEMA_VERSION}). Upgrade the CLI, or delete the cache DB to start fresh.`, @@ -164,7 +166,7 @@ export class CacheService { `); if (userVersion < CACHE_SCHEMA_VERSION) { - this.db.pragma(`user_version = ${CACHE_SCHEMA_VERSION}`); + this.db.exec(`PRAGMA user_version = ${CACHE_SCHEMA_VERSION}`); } const row = this.db.prepare('SELECT COALESCE(SUM(size), 0) as total FROM cache').get() as { total: number }; diff --git a/tests/e2e/cli-cache-degraded.e2e.test.ts b/tests/e2e/cli-cache-degraded.e2e.test.ts index 6d1b75a..8df2e16 100644 --- a/tests/e2e/cli-cache-degraded.e2e.test.ts +++ b/tests/e2e/cli-cache-degraded.e2e.test.ts @@ -1,9 +1,10 @@ /** * E2E Tests for degraded cache backend - * Simulates a native-module load failure (e.g. ABI mismatch after a - * `brew upgrade node`) by hijacking module resolution for better-sqlite3 - * in the CLI subprocess. The CLI must keep translating/writing with the - * cache disabled, warn exactly once, and never touch the cache database. + * Simulates the storage backend failing to load (e.g. node:sqlite + * missing on a pre-24 Node runtime) by hijacking module resolution for + * node:sqlite in the CLI subprocess. The CLI must keep translating/ + * writing with the cache disabled, warn exactly once, and never touch + * the cache database. */ import { spawn, ChildProcess } from 'child_process'; @@ -11,19 +12,13 @@ import * as fs from 'fs'; import * as path from 'path'; import { createTestConfigDir, createTestDir, makeNodeRunCLI } from '../helpers'; -const ABI_ERROR_SNIPPET = - 'was compiled against a different Node.js version using NODE_MODULE_VERSION 137'; - const BREAK_SQLITE_PRELOAD = `'use strict'; const { registerHooks } = require('node:module'); registerHooks({ resolve(specifier, context, nextResolve) { - if (specifier === 'better-sqlite3') { - const err = new Error( - "The module '/fake/better_sqlite3.node' ${ABI_ERROR_SNIPPET}. " + - 'This version of Node.js requires NODE_MODULE_VERSION 127.', - ); - err.code = 'ERR_DLOPEN_FAILED'; + if (specifier === 'node:sqlite' || specifier === 'sqlite') { + const err = new Error('No such built-in module: node:sqlite'); + err.code = 'ERR_UNKNOWN_BUILTIN_MODULE'; throw err; } return nextResolve(specifier, context); @@ -84,7 +79,7 @@ describe('CLI with unavailable cache backend E2E', () => { runCLIAll = helpers.runCLIAll; runCLIExpectError = helpers.runCLIExpectError; - preloadPath = path.join(testFiles.path, 'break-better-sqlite3.cjs'); + preloadPath = path.join(testFiles.path, 'break-node-sqlite.cjs'); fs.writeFileSync(preloadPath, BREAK_SQLITE_PRELOAD); const mockPort = await startMockServer(); diff --git a/tests/fixtures/cache-better-sqlite3.db b/tests/fixtures/cache-better-sqlite3.db new file mode 100644 index 0000000..a6449f7 Binary files /dev/null and b/tests/fixtures/cache-better-sqlite3.db differ diff --git a/tests/unit/cache-backcompat.test.ts b/tests/unit/cache-backcompat.test.ts new file mode 100644 index 0000000..788fe81 --- /dev/null +++ b/tests/unit/cache-backcompat.test.ts @@ -0,0 +1,94 @@ +/** + * Back-compat: CacheService must open and read a cache database FILE + * created by better-sqlite3 (the storage backend up to v1.x). This is + * the one place real user data is at risk in the node:sqlite migration. + * + * The fixture (tests/fixtures/cache-better-sqlite3.db) was generated by + * better-sqlite3 12.11.1 (SQLite 3.53.2) mirroring CacheService's real + * on-disk layout: WAL journal mode, user_version = 1, cache table with + * timestamp index, three rows stamped 2026-07-01T00:00:00Z. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CacheService } from '../../src/storage/cache'; + +const FIXTURE = path.join(__dirname, '..', 'fixtures', 'cache-better-sqlite3.db'); + +describe('CacheService better-sqlite3 back-compat', () => { + let testCacheDir: string; + let testCachePath: string; + let cache: CacheService | undefined; + + beforeEach(() => { + testCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-cli-backcompat-')); + testCachePath = path.join(testCacheDir, 'cache.db'); + fs.copyFileSync(FIXTURE, testCachePath); + }); + + afterEach(() => { + cache?.close(); + cache = undefined; + fs.rmSync(testCacheDir, { recursive: true, force: true }); + }); + + function openFixture(): CacheService { + // ttl: 0 disables expiry so the fixture's fixed timestamps stay readable. + cache = new CacheService({ dbPath: testCachePath, ttl: 0 }); + return cache; + } + + it('opens the database without treating it as corrupt', () => { + openFixture(); + + const corruptBackups = fs.readdirSync(testCacheDir).filter((f) => f.includes('.corrupt-')); + expect(corruptBackups).toEqual([]); + }); + + it('reads all entries written by better-sqlite3', () => { + const service = openFixture(); + + expect(service.get('translate:v1:hello')).toEqual({ + text: 'Hallo', + detectedSourceLang: 'en', + }); + expect(service.get('translate:v1:world')).toEqual({ + text: 'Welt', + detectedSourceLang: 'en', + }); + expect(service.get('write:v1:improve')).toEqual({ text: 'An improved sentence.' }); + }); + + it('reports accurate stats for the pre-existing entries', () => { + const service = openFixture(); + + const stats = service.stats(); + expect(stats.entries).toBe(3); + expect(stats.totalSize).toBeGreaterThan(0); + }); + + it('writes new entries alongside the old ones', () => { + const service = openFixture(); + + service.set('translate:v1:new', { text: 'Neu' }); + + expect(service.get('translate:v1:new')).toEqual({ text: 'Neu' }); + expect(service.get('translate:v1:hello')).toEqual({ + text: 'Hallo', + detectedSourceLang: 'en', + }); + expect(service.stats().entries).toBe(4); + }); + + it('survives a close and reopen cycle', () => { + const service = openFixture(); + service.set('translate:v1:new', { text: 'Neu' }); + service.close(); + cache = undefined; + + const reopened = openFixture(); + expect(reopened.get('translate:v1:new')).toEqual({ text: 'Neu' }); + expect(reopened.stats().entries).toBe(4); + }); +}); diff --git a/tests/unit/cache-native-failure.test.ts b/tests/unit/cache-native-failure.test.ts index 7d08541..73555fc 100644 --- a/tests/unit/cache-native-failure.test.ts +++ b/tests/unit/cache-native-failure.test.ts @@ -1,9 +1,9 @@ /** - * Tests for CacheService native-module load failure classification. - * A native binding that fails to load (e.g. ABI mismatch after a Node - * upgrade) must NOT be treated as database corruption: the DB file and - * its -wal/-shm sidecars must be left untouched. Genuine open failures - * must still trigger the rename-aside recovery path. + * Tests for CacheService storage-backend load failure classification. + * A backend that fails to load (e.g. node:sqlite missing on an older + * Node runtime) must NOT be treated as database corruption: the DB file + * and its -wal/-shm sidecars must be left untouched. Genuine open + * failures must still trigger the rename-aside recovery path. */ import * as fs from 'fs'; @@ -24,11 +24,11 @@ let mockConstructorError: Error | null = null; // A plain function, not jest.fn(): the project's resetMocks setting would // strip a jest.fn implementation between tests. -jest.mock('better-sqlite3', () => - function MockDatabase(): never { +jest.mock('node:sqlite', () => ({ + DatabaseSync: function MockDatabaseSync(): never { throw mockConstructorError ?? new Error('mockConstructorError not set'); }, -); +})); import { CacheService } from '../../src/storage/cache'; import { Logger } from '../../src/utils/logger'; diff --git a/tests/unit/cache-service.test.ts b/tests/unit/cache-service.test.ts index 9858674..92ffe27 100644 --- a/tests/unit/cache-service.test.ts +++ b/tests/unit/cache-service.test.ts @@ -73,14 +73,14 @@ describe('CacheService', () => { it('should use WAL journal mode', () => { const db = (cacheService as any).db; - const result = db.pragma('journal_mode', { simple: true }); - expect(result).toBe('wal'); + const result = db.prepare('PRAGMA journal_mode').get(); + expect(result).toEqual({ journal_mode: 'wal' }); }); it('should stamp user_version = 1 on a fresh database', () => { const db = (cacheService as any).db; - const result = db.pragma('user_version', { simple: true }); - expect(result).toBe(1); + const result = db.prepare('PRAGMA user_version').get(); + expect(result).toEqual({ user_version: 1 }); }); it('should upgrade-stamp a pre-versioned (user_version=0) database in place', () => { @@ -88,14 +88,14 @@ describe('CacheService', () => { // reopen via a new CacheService, verify it got stamped to 1 and // existing data survived. const db = (cacheService as any).db; - db.pragma('user_version = 0'); + db.exec('PRAGMA user_version = 0'); cacheService.set('preexisting', { text: 'survives' }); cacheService.close(); const reopened = new CacheService({ dbPath: testCachePath }); try { const reopenedDb = (reopened as any).db; - expect(reopenedDb.pragma('user_version', { simple: true })).toBe(1); + expect(reopenedDb.prepare('PRAGMA user_version').get()).toEqual({ user_version: 1 }); expect(reopened.get('preexisting')).toEqual({ text: 'survives' }); } finally { reopened.close();