From 2954209a1a0ad98a1f49987a607b76aeeead1cfb Mon Sep 17 00:00:00 2001 From: "d.bronnikov" Date: Thu, 27 Aug 2026 17:00:47 +0800 Subject: [PATCH] fix(@angular/build): preserve binary values in the SQLite cache store The SQLite cache store serialized values with `JSON.stringify` and read them back with `JSON.parse`. Several cached values contain binary data: the JavaScript transformer stores its worker output as a `Uint8Array` (`Cache`), and `CachedLoadResultEntry.contents` is typed as `string | Uint8Array`. A JSON round trip cannot represent typed arrays, so those values came back from disk as plain objects (`{"0":105,"1":109,...}`) and were handed to esbuild as load result contents, failing the build with `"contents" must be a string or a Uint8Array`. The failure only appeared from the second build onwards, because the first build serves the value from the in-memory cache layer and the value is only corrupted once it is read back from disk. Values are now persisted using the V8 structured clone serialization API (`node:v8`), which supports typed arrays natively and matches the behavior of the LMDB store. The `value` column is declared as `BLOB` accordingly. SQLite column types are dynamic, so a stored value is checked at runtime before it is deserialized; values that are not binary, or whose payload is corrupt, are treated as a cache miss and recreated. The store is only reached when LMDB fails to load, which is why this went unnoticed on most systems. A common trigger is a prebuilt `@lmdb/lmdb-linux-x64` binary requiring a newer glibc than the host provides, for example on Ubuntu 20.04, Debian 11, or RHEL/CentOS 8. The fallback can also be selected explicitly with `NG_BUILD_CACHE_STORE=sqlite`. Closes #33841 --- .../src/tools/esbuild/sqlite-cache-store.ts | 26 ++++-- .../tools/esbuild/sqlite-cache-store_spec.ts | 79 ++++++++++++++++++- 2 files changed, 96 insertions(+), 9 deletions(-) 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 4f50515749df..cbd51d345ddd 100644 --- a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts +++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts @@ -7,8 +7,17 @@ */ import { DatabaseSync, StatementSync } from 'node:sqlite'; +import { deserialize, serialize } from 'node:v8'; import { Cache, PersistentCacheStore } from './cache'; +/** + * A persistent cache store backed by SQLite. + * + * Values are persisted with the V8 structured clone serialization API instead of JSON. Cached + * values include binary data such as the `Uint8Array` output of the JavaScript transformer and + * the `contents` of an esbuild load result. A JSON round-trip converts those into plain objects + * (`{"0":105,"1":109,...}`), which breaks consumers on any build that reads them back from disk. + */ export class SqliteCacheStore implements PersistentCacheStore { #db: DatabaseSync | undefined; #getStmt: StatementSync | undefined; @@ -35,7 +44,7 @@ export class SqliteCacheStore implements PersistentCacheStore { this.#db.exec('PRAGMA temp_store = MEMORY;'); this.#db.exec('PRAGMA mmap_size = 268435456;'); this.#db.exec( - 'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', + 'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', ); this.#getStmt = this.#db.prepare('SELECT value FROM cache WHERE key = ?'); @@ -92,15 +101,18 @@ export class SqliteCacheStore implements PersistentCacheStore { // 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; + // SQLite column types are dynamic, so the stored value is only known at runtime. + const row = this.#getStmt?.get(key) as { value: unknown } | undefined; if (row) { this.#queueAccessUpdate(key); - try { - return JSON.parse(row.value); - } catch { - return undefined; + if (row.value instanceof Uint8Array) { + try { + return deserialize(row.value); + } catch { + // Treat corrupt or unparseable cached payloads as a cache miss. + } } } @@ -116,7 +128,7 @@ export class SqliteCacheStore implements PersistentCacheStore { async set(key: string, value: unknown): Promise { this.#ensureDb(); this.#pendingAccessedKeys.delete(key); - this.#setStmt?.run(key, JSON.stringify(value)); + this.#setStmt?.run(key, serialize(value)); return this; } 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 679bff21de20..75ab1a358573 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 @@ -36,6 +36,81 @@ describe('SqliteCacheStore', () => { expect(result).toEqual(data); }); + it('should preserve binary values', async () => { + const data = new TextEncoder().encode('export const value = 1;\n'); + await store.set('binary-key', data); + + const result = await store.get('binary-key'); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(data); + }); + + it('should preserve binary values nested within an object', async () => { + const data = { + contents: new TextEncoder().encode('export const value = 1;\n'), + loader: 'js', + watchFiles: ['/some/file.js'], + }; + await store.set('nested-binary-key', data); + + const result = await store.get('nested-binary-key'); + expect(result.contents).toBeInstanceOf(Uint8Array); + expect(result).toEqual(data); + }); + + it('should preserve binary values across store instances', async () => { + const data = new TextEncoder().encode('export const value = 1;\n'); + await store.set('persisted-binary-key', data); + store.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('persisted-binary-key'); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(data); + } finally { + reopenedStore.close(); + } + }); + + it('should treat a corrupt payload as a cache miss', async () => { + await store.set('corrupt-key', 'value'); + store.close(); + + // Simulate an entry with an invalid/corrupt payload that fails deserialization. + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare('UPDATE cache SET value = ? WHERE key = ?') + .run(new Uint8Array([0x00, 0x01, 0x02]), 'corrupt-key'); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('corrupt-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(); + + // SQLite column types are dynamic, so a stored value is not guaranteed to be binary. + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb.prepare('UPDATE cache SET value = ? WHERE key = ?').run('"value"', 'text-key'); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('text-key')).toBeUndefined(); + } finally { + reopenedStore.close(); + } + }); + it('should return undefined for non-existent key', async () => { const result = await store.get('missing-key'); expect(result).toBeUndefined(); @@ -89,8 +164,8 @@ describe('SqliteCacheStore', () => { 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. + // Keys 'k1', 'k2', 'k3' are small (each is 12 bytes: 2 byte key + 10 byte serialized value). + // Total size of k1 + k2 + k3 is 36 bytes, which exceeds the 25 bytes limit. const sizeStore = new SqliteCacheStore(cachePath, 25); // Set k1, then k2, then k3.