diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 55818229c523..5d9ec48b57d3 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -24,6 +24,7 @@ import * as path from 'node:path'; import { maxWorkers, useTypeChecking } from '../../../utils/environment-options'; import { AngularHostOptions } from '../../angular/angular-host'; import { AngularCompilation, DiagnosticModes, NoopCompilation } from '../../angular/compilation'; +import { type PersistentCacheStore, createPersistentCacheStore } from '../cache'; import { JavaScriptTransformer } from '../javascript-transformer'; import { LoadResultCache, createCachedLoad } from '../load-result-cache'; import { logCumulativeDurations, profileAsync, resetCumulativeDurations } from '../profiling'; @@ -62,9 +63,7 @@ export interface CompilerPluginOptions { export function createCompilerPlugin( pluginOptions: CompilerPluginOptions, compilationContextOrCompilation: - | AngularCompilationContext - | AngularCompilation - | (() => Promise), + AngularCompilationContext | AngularCompilation | (() => Promise), stylesheetBundler: ComponentStylesheetBundler, ): Plugin { return { @@ -76,20 +75,21 @@ export function createCompilerPlugin( // Initialize a worker pool for JavaScript transformations. // Webcontainers currently do not support this persistent cache store. - let cacheStore: import('../lmdb-cache-store').LmdbCacheStore | undefined; + let cacheStore: PersistentCacheStore | undefined; if (pluginOptions.sourceFileCache?.persistentCachePath && !process.versions.webcontainer) { try { - const { LmdbCacheStore } = await import('../lmdb-cache-store'); - cacheStore = new LmdbCacheStore( - path.join(pluginOptions.sourceFileCache.persistentCachePath, 'angular-compiler.db'), + cacheStore = await createPersistentCacheStore( + path.join(pluginOptions.sourceFileCache.persistentCachePath, 'angular-compiler'), ); } catch (e) { setupWarnings.push({ text: 'Unable to initialize JavaScript cache storage.', location: null, notes: [ - // Only show first line of lmdb load error which has platform support listed - { text: (e as Error)?.message.split('\n')[0] ?? `${e}` }, + ...(e as Error).message + .split('\n') + .slice(1) + .map((text) => ({ text })), { text: 'This will not affect the build output content but may result in slower builds.', }, @@ -666,7 +666,7 @@ function createCompilerOptionsTransformer( // If 'useDefineForClassFields' is already defined in the users project leave the value as is. // Otherwise fallback to false due to https://github.com/microsoft/TypeScript/issues/45995 // which breaks the deprecated `@Effects` NGRX decorator and potentially other existing code as well. - compilerOptions.target = 9 /** ES2022 */; + compilerOptions.target = 9; /** ES2022 */ compilerOptions.useDefineForClassFields ??= false; // Only add the warning on the initial build diff --git a/packages/angular/build/src/tools/esbuild/cache.ts b/packages/angular/build/src/tools/esbuild/cache.ts index 0634ec5268e8..9993023893fb 100644 --- a/packages/angular/build/src/tools/esbuild/cache.ts +++ b/packages/angular/build/src/tools/esbuild/cache.ts @@ -11,6 +11,8 @@ * Provides infrastructure for common caching functionality within the build system. */ +import { assertIsError } from '../../utils/error'; + /** * A backing data store for one or more Cache instances. * The interface is intentionally designed to support using a JavaScript @@ -38,6 +40,16 @@ export interface CacheStore { set(key: string, value: V): this | Promise; } +/** + * A persistent backing data store that supports namespace partitioning + * and manual lifecycle close operations. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface PersistentCacheStore extends CacheStore { + createCache(namespace: string): Cache; + close(): void | Promise; +} + /** * A cache object that allows accessing and storing key/value pairs in * an underlying CacheStore. This class is the primary method for consumers @@ -224,3 +236,37 @@ export class MemoryCache extends Cache> { return this.store.entries(); } } + +/** + * Creates and returns a persistent cache store. + * Attempts to use the native LMDB store first, and falls back to the built-in SQLite store + * if LMDB fails to initialize. + * + * @param baseCachePath The base path of the cache file/directory without suffix/extension. + * @returns A promise resolving to a PersistentCacheStore instance. + */ +export async function createPersistentCacheStore( + baseCachePath: string, +): Promise { + try { + const { LmdbCacheStore } = await import('./lmdb-cache-store'); + + return new LmdbCacheStore(baseCachePath + '.db'); + } catch (lmdbError) { + try { + const { SqliteCacheStore } = await import('./sqlite-cache-store'); + + return new SqliteCacheStore(baseCachePath + '-sqlite.db'); + } catch (sqliteError) { + assertIsError(lmdbError); + assertIsError(sqliteError); + + throw new Error( + 'Unable to initialize JavaScript cache storage.\n' + + `LMDB error: ${lmdbError.message.split('\n')[0]}\n` + + `SQLite error: ${sqliteError.message.split('\n')[0]}`, + { cause: sqliteError }, + ); + } + } +} diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 9e17714df807..af13226a68d4 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -11,7 +11,7 @@ import { createHash } from 'node:crypto'; import { extname, join } from 'node:path'; import { WorkerPool } from '../../utils/worker-pool'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; -import type { LmdbCacheStore } from './lmdb-cache-store'; +import { type PersistentCacheStore, createPersistentCacheStore } from './cache'; /** * A keyword used to indicate if a JavaScript file may require inlining of translations. @@ -38,7 +38,7 @@ export interface I18nInlinerOptions { export class I18nInliner { #cacheInitFailed = false; #workerPool: WorkerPool; - #cache: LmdbCacheStore | undefined; + #cache: PersistentCacheStore | undefined; readonly #localizeFiles: ReadonlyMap; readonly #unmodifiedFiles: Array; @@ -162,8 +162,10 @@ export class I18nInliner { const result = await this.#workerPool.run({ filename, locale, translation }); if (this.#cache && cacheKey) { - // Failure to set the value should not fail the transform - await this.#cache.set(cacheKey, result).catch(() => {}); + try { + // Failure to set the value should not fail the transform + await this.#cache.set(cacheKey, result); + } catch {} } return result; @@ -273,9 +275,7 @@ export class I18nInliner { // Initialize a persistent cache for i18n transformations. try { - const { LmdbCacheStore } = await import('./lmdb-cache-store'); - - this.#cache = new LmdbCacheStore(join(persistentCachePath, 'angular-i18n.db')); + this.#cache = await createPersistentCacheStore(join(persistentCachePath, 'angular-i18n')); } catch { this.#cacheInitFailed = true; diff --git a/packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts b/packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts index dba108285342..d8f95b8de9da 100644 --- a/packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts +++ b/packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts @@ -7,9 +7,9 @@ */ import { RootDatabase, open } from 'lmdb'; -import { Cache, CacheStore } from './cache'; +import { Cache, PersistentCacheStore } from './cache'; -export class LmdbCacheStore implements CacheStore { +export class LmdbCacheStore implements PersistentCacheStore { readonly #cacheFileUrl; #db: RootDatabase | undefined; diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts new file mode 100644 index 000000000000..3d594668d44f --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { DatabaseSync, StatementSync } from 'node:sqlite'; +import { Cache, PersistentCacheStore } from './cache'; + +export class SqliteCacheStore implements PersistentCacheStore { + #db: DatabaseSync | undefined; + #getStmt: StatementSync | undefined; + #hasStmt: StatementSync | undefined; + #setStmt: StatementSync | undefined; + #updateAccessedStmt: StatementSync | undefined; + + constructor( + readonly cachePath: string, + private readonly maxPayloadSize = 1024 * 1024 * 1024, + private readonly ttlDays = 14, + ) {} + + #ensureDb(): DatabaseSync { + if (!this.#db) { + this.#db = new DatabaseSync(this.cachePath); + // Optimize SQLite for cache usage + this.#db.exec('PRAGMA auto_vacuum = FULL;'); + this.#db.exec('PRAGMA journal_mode = WAL;'); + this.#db.exec('PRAGMA synchronous = NORMAL;'); + this.#db.exec( + 'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', + ); + + this.#getStmt = this.#db.prepare('SELECT value FROM cache WHERE key = ?'); + this.#hasStmt = this.#db.prepare('SELECT 1 FROM cache WHERE key = ?'); + this.#setStmt = this.#db.prepare( + 'INSERT OR REPLACE INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())', + ); + this.#updateAccessedStmt = this.#db.prepare( + 'UPDATE cache SET last_accessed = unixepoch() WHERE key = ?', + ); + } + + return this.#db; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async get(key: string): Promise { + this.#ensureDb(); + const row = this.#getStmt?.get(key) as { value: string } | undefined; + + if (row) { + this.#updateAccessedStmt?.run(key); + + try { + return JSON.parse(row.value); + } catch { + return undefined; + } + } + + return undefined; + } + + has(key: string): boolean { + this.#ensureDb(); + + return !!this.#hasStmt?.get(key); + } + + async set(key: string, value: unknown): Promise { + this.#ensureDb(); + this.#setStmt?.run(key, JSON.stringify(value)); + + return this; + } + + createCache(namespace: string): Cache { + return new Cache(this, namespace); + } + + close(): void { + if (this.#db) { + try { + // 1. Delete items older than N days + this.#db + .prepare("DELETE FROM cache WHERE last_accessed < unixepoch('now', ?);") + .run(`-${this.ttlDays} days`); + + // 2. Prune oldest items if payload exceeds maxPayloadSize + const pruneStmt = this.#db.prepare(` + DELETE FROM cache WHERE key IN ( + SELECT key FROM ( + SELECT key, + sum(length(key) + length(value)) OVER (ORDER BY last_accessed DESC, key DESC) as running_size + FROM cache + ) WHERE running_size > ? + ); + `); + pruneStmt.run(this.maxPayloadSize); + } catch { + // Pruning errors should not block build success + } finally { + this.#getStmt = undefined; + this.#hasStmt = undefined; + this.#setStmt = undefined; + this.#updateAccessedStmt = undefined; + + this.#db.close(); + this.#db = undefined; + } + } + } +} diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts b/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts new file mode 100644 index 000000000000..bd834aca5f12 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { SqliteCacheStore } from './sqlite-cache-store'; + +describe('SqliteCacheStore', () => { + let tempDir: string; + let cachePath: string; + let store: SqliteCacheStore; + + beforeEach(async () => { + // Create a temporary directory in the workspace for testing + tempDir = join(__dirname, `sqlite-test-temp-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + cachePath = join(tempDir, 'test-cache.db'); + store = new SqliteCacheStore(cachePath); + }); + + afterEach(async () => { + store.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should store and retrieve a value', async () => { + const data = { foo: 'bar', list: [1, 2, 3] }; + await store.set('test-key', data); + + const result = await store.get('test-key'); + expect(result).toEqual(data); + }); + + it('should return undefined for non-existent key', async () => { + const result = await store.get('missing-key'); + expect(result).toBeUndefined(); + }); + + it('should correctly report existence of a key', async () => { + expect(store.has('exist-key')).toBeFalse(); + + await store.set('exist-key', 'value'); + expect(store.has('exist-key')).toBeTrue(); + }); + + it('should overwrite values for existing keys', async () => { + await store.set('overwrite-key', 'initial'); + await store.set('overwrite-key', 'updated'); + + const result = await store.get('overwrite-key'); + expect(result).toBe('updated'); + }); + + it('should prune items older than TTL on close', async () => { + // Write two items + await store.set('new-key', 'new-val'); + await store.set('old-key', 'old-val'); + + // Close the store so we can modify the DB safely + store.close(); + + // Directly open database to update timestamp of 'old-key' to 15 days ago + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare('UPDATE cache SET last_accessed = unixepoch() - 15 * 24 * 3600 WHERE key = ?') + .run('old-key'); + directDb.close(); + + // Reopen store with a 14-day TTL, access it to open connection, then close to trigger pruning + const pruneStore = new SqliteCacheStore(cachePath, undefined, 14); + expect(pruneStore.has('new-key')).toBeTrue(); + pruneStore.close(); + + // Verify 'old-key' is gone but 'new-key' remains + const checkStore = new SqliteCacheStore(cachePath); + expect(checkStore.has('old-key')).toBeFalse(); + expect(checkStore.has('new-key')).toBeTrue(); + checkStore.close(); + }); + + it('should prune oldest items when total payload size exceeds maximum on close', async () => { + // Close the default store so we can instantiate one with a small limit + store.close(); + + // Create a store with a tiny size limit (e.g. 25 bytes) + // Keys 'k1', 'k2', 'k3' are small (each is 10 bytes: key + JSON.stringify(value)). + // Total size of k1 + k2 + k3 is 30 bytes, which exceeds the 25 bytes limit. + const sizeStore = new SqliteCacheStore(cachePath, 25); + + // Set k1, then k2, then k3. + // Order of inserts: k1 (oldest), k2 (middle), k3 (newest) + await sizeStore.set('k1', 'value1'); + await sizeStore.set('k2', 'value2'); + await sizeStore.set('k3', 'value3'); + + // Close sizeStore to trigger pruning + sizeStore.close(); + + // Reopen to check which keys were kept + const checkStore = new SqliteCacheStore(cachePath); + // k3 (newest) and k2 (middle) should be kept (~20 bytes total) + // k1 (oldest) should be pruned to get under 25 bytes. + expect(checkStore.has('k3')).toBeTrue(); + expect(checkStore.has('k2')).toBeTrue(); + expect(checkStore.has('k1')).toBeFalse(); + checkStore.close(); + }); +});