Files
renovate/lib/util/cache/package/impl/sqlite.ts
T
Sebastian Poxhofer 716baf630b fix(linters): add rules for filesystem helpers and Date (#44685)
* feat(lint): add renovate/prefer-fs-util and renovate/prefer-luxon rules

Two shared-util substitution rules, both at warn level:

- renovate/prefer-fs-util flags value imports of fs, node:fs,
  fs/promises, node:fs/promises, fs-extra and fs-extra/esm in lib/
  source files outside lib/util/fs/, pointing to the scoped helpers
  from lib/util/fs. Spec files and type-only imports are exempt.
  A config-only change via no-restricted-imports was considered but
  rejected: that rule is at error severity globally and per-path warn
  severity is not expressible, so the 13 existing call sites would
  break the baseline.

- renovate/prefer-luxon flags new Date(...) and Date.now() in lib/
  source files, pointing to Luxon per best-practices. Date/time
  utility modules (lib/util/date, lib/util/pretty-time,
  lib/util/timestamp) and spec files (vi.setSystemTime etc.) are
  exempt.

Baseline: 13 prefer-fs-util and 45 prefer-luxon warnings, zero errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: use Luxon instead of new Date() at remaining call sites

Fixes all renovate/prefer-luxon violations for `new Date(...)`:

- `new Date().toISOString()` -> `DateTime.utc().toISO()` (byte-identical
  output) in the http cache provider, schedule logging and PR cache.
- `new Date()` -> `DateTime.now().toJSDate()` for the git commit-date
  fallback and the rubygems `syncedAt` markers (same `Date` values).
- rust-release-channel now computes the Rust 1.0 release date and nightly
  version dates via `DateTime.utc(...)`; previously `new Date(y, m, d)`
  used the local timezone while the epoch used UTC, so the comparison was
  timezone-dependent at the boundary date. Tests run with TZ=UTC, where
  behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: use lib/util/fs helpers in repository worker

Fixes renovate/prefer-fs-util violations outside bootstrap code:

- `fs.ensureDir()` -> `ensureDir()` from lib/util/fs (same fs-extra call
  underneath; it no-ops on an empty dir name, so the repository worker
  spec now simulates the init failure via a rejected `initRepo` instead
  of relying on `fs.ensureDir('')` throwing).
- `fs.remove(privateCacheDir())` -> `cachePathExists()` + `rmCache()`,
  keeping the remove-if-present semantics while asserting cacheDir
  containment.

Two call sites keep raw imports with reasoned disable comments:

- lib/util/modules.ts synchronously enumerates Renovate's own installed
  module directories at import time.
- lib/util/cache/package/impl/sqlite.ts receives cacheDir as an explicit
  argument during global initialization, before GlobalConfig (which the
  scoped cache helpers resolve against) contains cacheDir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(lint): promote prefer-fs-util and prefer-luxon to error

Both rules are narrowed to what is actually accepted practice before
being promoted:

prefer-luxon no longer flags `Date.now()`: it returns UTC-based epoch
milliseconds used for elapsed-time measurement and TTL arithmetic, which
is timezone-independent by construction. The mined reviewer feedback
behind this rule ("use Luxon for date arithmetic outside util") targets
timezone-dependent date construction/formatting, which cannot occur with
a raw millis number; the rule keeps flagging `new Date(...)`.

prefer-fs-util exempts code that legitimately needs raw fs access
because the scoped lib/util/fs helpers resolve against GlobalConfig
localDir/cacheDir, which does not exist yet or does not apply there:

- lib/logger/: log-file streams at user-configured system paths during
  bootstrap; lib/util/fs also depends on the logger (import cycle).
- lib/workers/global/: creates baseDir/cacheDir themselves and
  reads/writes user-supplied paths before GlobalConfig is seeded.
- lib/config-validator.ts: standalone CLI without GlobalConfig.
- lib/util/git/: owns the localDir lifecycle (clone, emptyDir, symlinks)
  and private-key files with sync cleanup on process exit.

Also fixes a pre-existing tsc error in prefer-fs-util.js: `importKind`
is a TypeScript-ESTree extension missing from eslint's base AST types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(fs): make rmCache ignore missing paths

All rmCache callers use it for cleanup, where a missing path is a
no-op, so bake force: true in instead of guarding call sites with
an existence check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(linters): exempt lib/instrumentation from prefer-fs-util

`lib/instrumentation/file-exporter.ts` landed on main while this branch
was open and imports `node:fs/promises` directly.

The exporter writes to a user-configured system path
(`RENOVATE_TRACING_FILE_EXPORTER_PATH`) outside `localDir`/`cacheDir`,
so the scoped helpers do not apply. `lib/instrumentation` is also
deliberately kept out of the module graph of instrumented libraries
(see the `⚠️` note in `lib/renovate.ts`), so it must not pull in
`lib/util/fs` and its config/logger dependencies. Exempt the directory,
as is already done for `lib/logger`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: shorten doc

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:00:44 +00:00

155 lines
4.7 KiB
TypeScript

import type { DatabaseSync, StatementSync } from 'node:sqlite';
// oxlint-disable-next-line renovate/prefer-fs-util -- create() runs during global initialization with cacheDir passed as an explicit argument, before GlobalConfig (which the scoped cache helpers resolve against) contains cacheDir
import fs from 'fs-extra';
import upath from 'upath';
import { logger } from '../../../../logger/index.ts';
import { getEnv } from '../../../env.ts';
import { ensureDir } from '../../../fs/index.ts';
import { parseInteger } from '../../../number.ts';
import type { PackageCacheNamespace } from '../types.ts';
import { PackageCacheBase } from './base.ts';
const { exists } = fs;
export class PackageCacheSqlite extends PackageCacheBase {
static async create(cacheDir: string): Promise<PackageCacheSqlite> {
const { DatabaseSync: Sqlite } = await import('node:sqlite');
const sqliteDir = upath.join(cacheDir, 'renovate/renovate-cache-sqlite');
await ensureDir(sqliteDir);
const sqliteFile = upath.join(sqliteDir, 'db.sqlite');
if (await exists(sqliteFile)) {
logger.debug(`Using SQLite package cache: ${sqliteFile}`);
} else {
logger.debug(`Creating SQLite package cache: ${sqliteFile}`);
}
const { RENOVATE_X_SQLITE_BUSY_TIMEOUT } = getEnv();
const timeout = parseInteger(RENOVATE_X_SQLITE_BUSY_TIMEOUT, 5000);
const client = new Sqlite(sqliteFile, { timeout });
return new PackageCacheSqlite(client);
}
private readonly upsertStatement: StatementSync;
private readonly getStatement: StatementSync;
private readonly deleteStatement: StatementSync;
private readonly deleteExpiredRows: StatementSync;
private readonly countStatement: StatementSync;
readonly client: DatabaseSync;
private constructor(client: DatabaseSync) {
super();
this.client = client;
client.exec('PRAGMA journal_mode = WAL');
client.exec("PRAGMA encoding = 'UTF-8'");
client.exec(
`
CREATE TABLE IF NOT EXISTS package_cache (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
expiry INTEGER NOT NULL,
data BLOB NOT NULL,
PRIMARY KEY (namespace, key)
)
`,
);
client.exec('CREATE INDEX IF NOT EXISTS expiry ON package_cache (expiry)');
this.upsertStatement = client.prepare(`
INSERT INTO package_cache (namespace, key, data, expiry)
VALUES (@namespace, @key, @data, unixepoch() + @ttlSeconds)
ON CONFLICT (namespace, key) DO UPDATE SET
data = @data,
expiry = unixepoch() + @ttlSeconds
`);
this.getStatement = client.prepare(
`
SELECT data FROM package_cache
WHERE
namespace = @namespace AND key = @key AND expiry > unixepoch()
`,
);
this.deleteStatement = client.prepare(`
DELETE FROM package_cache
WHERE namespace = @namespace AND key = @key
`);
this.deleteExpiredRows = client.prepare(`
DELETE FROM package_cache
WHERE expiry <= unixepoch()
`);
this.countStatement = client.prepare(
'SELECT COUNT(*) as total FROM package_cache',
);
}
protected override writeRaw(
namespace: PackageCacheNamespace,
key: string,
data: Buffer,
ttlSeconds: number,
): void {
this.upsertStatement.run({
namespace,
key,
data,
ttlSeconds,
});
}
protected override readRaw(
namespace: PackageCacheNamespace,
key: string,
): Buffer | undefined {
const row = this.getStatement.get({ namespace, key }) as
| { data: Uint8Array }
| undefined;
if (!row) {
return undefined;
}
// `Buffer.from(row.data)` copies the whole payload.
// This call does zero copy.
return Buffer.from(
row.data.buffer,
row.data.byteOffset,
row.data.byteLength,
);
}
protected override rm(namespace: PackageCacheNamespace, key: string): void {
logger.trace({ namespace, key }, 'Removing cache entry');
this.deleteStatement.run({ namespace, key });
}
override destroy(): Promise<void> {
try {
const startTime = Date.now();
// `COUNT(*)` is always returning a row
const totalCount = this.countStatement.get()!.total as number;
const { changes: deletedCount } = this.deleteExpiredRows.run();
const durationMs = Date.now() - startTime;
logger.debug(
`SQLite package cache: deleted ${deletedCount} of ${totalCount} entries in ${durationMs}ms`,
);
} catch (err) {
logger.warn({ err }, 'SQLite package cache cleanup failed');
}
try {
this.client.close();
} catch (err) {
logger.warn({ err }, 'SQLite package cache close failed');
}
return Promise.resolve();
}
}