diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts index 981bafb9db74..abf1620be8d9 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts @@ -9,9 +9,31 @@ import { mkdirSync, rmSync } from 'node:fs'; import { dirname } from 'node:path'; import { DatabaseSync, StatementSync } from 'node:sqlite'; +import { promisify } from 'node:util'; import { deserialize, serialize } from 'node:v8'; +import { deflateRaw, inflateRawSync } from 'node:zlib'; import { Cache, PersistentCacheStore } from './cache'; +const deflateRawAsync = promisify(deflateRaw); + +/** + * Minimum payload size (in bytes) required to attempt compression. + * Smaller payloads generally do not achieve meaningful compression ratios, while larger payloads + * (such as transformed JavaScript modules from node_modules) achieve 70-80% size reduction + * and drastically reduce SQLite overflow pages. + */ +const COMPRESSION_THRESHOLD = 32 * 1024; // 32 KB + +/** + * Storage format discriminator values for cache table entries. + */ +const enum CacheFormat { + V8 = 0, + V8Compressed = 1, + RawBinary = 2, + RawBinaryCompressed = 3, +} + /** * Common SQLite primary result codes. * @see https://www.sqlite.org/rescode.html @@ -88,16 +110,26 @@ export class SqliteCacheStore implements PersistentCacheStore { db.exec('PRAGMA temp_store = MEMORY;'); db.exec('PRAGMA mmap_size = 268435456;'); db.exec( - 'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', + 'CREATE TABLE IF NOT EXISTS cache (' + + 'key TEXT PRIMARY KEY, ' + + 'value BLOB, ' + + 'format INTEGER NOT NULL DEFAULT 0, ' + + 'last_accessed INTEGER NOT NULL' + + ') WITHOUT ROWID;', ); + try { + db.exec('ALTER TABLE cache ADD COLUMN format INTEGER NOT NULL DEFAULT 0;'); + } catch { + // Ignore error if format column already exists + } db.exec( 'CREATE INDEX IF NOT EXISTS idx_cache_accessed ON cache (last_accessed DESC, key DESC);', ); - this.#getStmt = db.prepare('SELECT value FROM cache WHERE key = ?'); + this.#getStmt = db.prepare('SELECT value, format FROM cache WHERE key = ?'); this.#hasStmt = db.prepare('SELECT 1 FROM cache WHERE key = ?'); this.#setStmt = db.prepare( - 'INSERT OR REPLACE INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())', + 'INSERT OR REPLACE INTO cache (key, value, format, last_accessed) VALUES (?, ?, ?, unixepoch())', ); this.#updateAccessedStmt = db.prepare( 'UPDATE cache SET last_accessed = unixepoch() WHERE key = ?', @@ -206,14 +238,31 @@ export class SqliteCacheStore implements PersistentCacheStore { try { // SQLite column types are dynamic, so the stored value is only known at runtime. - const row = this.#getStmt?.get(key) as { value: unknown } | undefined; + const row = this.#getStmt?.get(key) as { value: unknown; format: CacheFormat } | undefined; if (row) { this.#queueAccessUpdate(key); if (row.value instanceof Uint8Array) { try { - return deserialize(row.value); + switch (row.format) { + case CacheFormat.RawBinary: + return row.value; + case CacheFormat.RawBinaryCompressed: { + const decompressed = inflateRawSync(row.value); + + return new Uint8Array( + decompressed.buffer, + decompressed.byteOffset, + decompressed.byteLength, + ); + } + case CacheFormat.V8Compressed: + return deserialize(inflateRawSync(row.value)); + case CacheFormat.V8: + default: + return deserialize(row.value); + } } catch { // Treat corrupt or unparseable cached payloads as a cache miss. } @@ -245,7 +294,21 @@ export class SqliteCacheStore implements PersistentCacheStore { try { this.#pendingAccessedKeys.delete(key); - this.#setStmt?.run(key, serialize(value)); + + const isBinary = value instanceof Uint8Array; + const data = isBinary ? value : serialize(value); + let format = isBinary ? CacheFormat.RawBinary : CacheFormat.V8; + let payload: Uint8Array = data; + + if (data.byteLength >= COMPRESSION_THRESHOLD) { + const compressed = await deflateRawAsync(data, { level: 1 }); + if (compressed.byteLength < data.byteLength) { + payload = compressed; + format = isBinary ? CacheFormat.RawBinaryCompressed : CacheFormat.V8Compressed; + } + } + + this.#setStmt?.run(key, payload, format); } catch { // Writing to cache is non-fatal and should not fail the build. } 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 index f5b9ccd1960d..4eac21f3b34b 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts @@ -224,6 +224,194 @@ describe('SqliteCacheStore', () => { expect(row.last_accessed).toBeGreaterThan(pastTimestamp); }); + it('should selectively compress entries that exceed the 32KB threshold', async () => { + const largeContent = + 'export const testFunction = () => { console.log("hello world"); };\n'.repeat(1000); + await store.set('large-key', largeContent); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('large-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format flag is 1 (CacheFormat.V8Compressed) and compressed size is drastically smaller + expect(row.format).toBe(1); + expect(row.value.byteLength).toBeLessThan(largeContent.length / 5); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('large-key'); + expect(result).toBe(largeContent); + } finally { + reopenedStore.close(); + } + }); + + it('should not compress entries below the 32KB threshold', async () => { + const smallContent = 'export const small = true;'; + await store.set('small-key', smallContent); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('small-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format flag is 0 (CacheFormat.V8) + expect(row.format).toBe(0); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('small-key'); + expect(result).toBe(smallContent); + } finally { + reopenedStore.close(); + } + }); + + it('should not compress entries exceeding the 32KB threshold if compression does not reduce size', async () => { + const { randomBytes } = await import('node:crypto'); + // High-entropy random bytes cannot be compressed by DEFLATE + const incompressibleData = new Uint8Array(randomBytes(48 * 1024)); + await store.set('incompressible-key', incompressibleData); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('incompressible-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify entry is stored uncompressed as raw binary (format 2 = CacheFormat.RawBinary) + expect(row.format).toBe(2); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('incompressible-key'); + expect(result).toEqual(incompressibleData); + } finally { + reopenedStore.close(); + } + }); + + it('should store raw Uint8Array directly with zero serialization', async () => { + const binaryData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + await store.set('binary-key', binaryData); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('binary-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format is 2 (CacheFormat.RawBinary) and value is the exact raw bytes + expect(row.format).toBe(2); + expect(row.value).toEqual(binaryData); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('binary-key'); + expect(result).toEqual(binaryData); + } finally { + reopenedStore.close(); + } + }); + + it('should selectively compress large Uint8Array entries', async () => { + const largeBinary = new Uint8Array(Buffer.from('export const x = 1;\n'.repeat(2000))); + await store.set('large-binary-key', largeBinary); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('large-binary-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format is 3 (CacheFormat.RawBinaryCompressed) + expect(row.format).toBe(3); + expect(row.value.byteLength).toBeLessThan(largeBinary.byteLength / 5); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('large-binary-key'); + expect(result).toEqual(largeBinary); + } finally { + reopenedStore.close(); + } + }); + + it('should seamlessly read legacy entries from databases created without a format column', async () => { + const { serialize } = await import('node:v8'); + const { DatabaseSync } = await import('node:sqlite'); + + // Create a legacy database without a format column + const directDb = new DatabaseSync(cachePath); + directDb.exec( + 'CREATE TABLE cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', + ); + const legacyPayload = serialize('legacy-content'); + directDb + .prepare('INSERT INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())') + .run('legacy-key', legacyPayload); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('legacy-key'); + expect(result).toBe('legacy-content'); + } finally { + reopenedStore.close(); + } + }); + + it('should treat a corrupted compressed payload as a cache miss', async () => { + // Initialize database + await store.set('init-key', 'init-val'); + store.close(); + + // Insert an entry with format = 1 (V8Compressed) followed by garbage + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare( + 'INSERT INTO cache (key, value, format, last_accessed) VALUES (?, ?, 1, unixepoch())', + ) + .run('corrupt-compressed-key', new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('corrupt-compressed-key')).toBeUndefined(); + } finally { + reopenedStore.close(); + } + }); + it('should treat a non-binary payload as a cache miss', async () => { await store.set('text-key', 'value'); store.close();