Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
#db: DatabaseSync | undefined;
#getStmt: StatementSync | undefined;
Expand All @@ -35,7 +44,7 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
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 = ?');
Expand Down Expand Up @@ -92,15 +101,18 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async get(key: string): Promise<any> {
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.
}
}
}

Expand All @@ -116,7 +128,7 @@ export class SqliteCacheStore implements PersistentCacheStore<unknown> {
async set(key: string, value: unknown): Promise<this> {
this.#ensureDb();
this.#pendingAccessedKeys.delete(key);
this.#setStmt?.run(key, JSON.stringify(value));
this.#setStmt?.run(key, serialize(value));

return this;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down