From 701e9dc63556d9233378304475bc9ef513becf0f Mon Sep 17 00:00:00 2001 From: arkhindi12 Date: Sun, 18 Oct 2026 11:05:36 -0300 Subject: [PATCH] feat(sdk): serialize/hydrate LruMemoCache for SSR (#977) The memo cache loses everything when a Next.js server component finishes, so the next request repeats the same contract queries. serialize() writes a versioned snapshot (expired entries dropped, LRU order preserved) and hydrate() restores it with the original absolute deadlines instead of restarting every TTL. Malformed input throws instead of leaving a half-filled cache, and metrics stay untouched so measuredSpeedupPercent is not fabricated. --- src/lru-memo-cache.ts | 124 ++++++++++++++++++- src/tests/lru-memo-cache-serialize.test.ts | 131 +++++++++++++++++++++ 2 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/tests/lru-memo-cache-serialize.test.ts diff --git a/src/lru-memo-cache.ts b/src/lru-memo-cache.ts index 5f50c164..d47a893b 100644 --- a/src/lru-memo-cache.ts +++ b/src/lru-memo-cache.ts @@ -17,6 +17,28 @@ export interface MemoCacheMetrics { measuredSpeedupPercent: number | null; } +/** One cache entry as written to JSON. `expiresAt` is epoch ms, or `null` for no TTL. */ +export interface SerializedCacheEntry { + key: K; + value: V; + expiresAt: number | null; +} + +/** Envelope written by {@link LruMemoCache.serialize}. Versioned so a future + * shape change can be rejected instead of mis-read. */ +export interface SerializedCache { + version: 1; + maxSize: number; + entries: SerializedCacheEntry[]; +} + +/** Cache keys are used as JSON object-free tuples, so only primitives are allowed. */ +function assertSerializableKey(key: unknown): asserts key is string | number { + if (typeof key !== 'string' && typeof key !== 'number') { + throw new TypeError(`LruMemoCache can only serialize string/number keys, got ${typeof key}`); + } +} + export class LruMemoCache { private readonly store = new Map(); /** Absolute expiry (epoch ms) for entries stored with a `ttlMs`. */ @@ -70,6 +92,15 @@ export class LruMemoCache { if (ttlMs !== undefined && (!Number.isFinite(ttlMs) || ttlMs <= 0)) { throw new RangeError('LruMemoCache ttlMs must be a positive finite number'); } + this.insert(key, value, ttlMs === undefined ? null : Date.now() + ttlMs); + } + + /** + * Shared insertion path for {@link set} and {@link hydrate}. `expiresAt` is an + * absolute epoch-ms deadline (or `null`), which lets hydration restore the + * original TTL instead of restarting it. + */ + private insert(key: K, value: V, expiresAt: number | null): void { if (this.store.has(key)) { this.remove(key); } else if (this.store.size >= this.maxSize) { @@ -77,7 +108,7 @@ export class LruMemoCache { if (oldest !== undefined) this.remove(oldest); } this.store.set(key, value); - if (ttlMs !== undefined) this.expiresAt.set(key, Date.now() + ttlMs); + if (expiresAt !== null) this.expiresAt.set(key, expiresAt); } recordHit(elapsedMs: number): void { @@ -99,6 +130,74 @@ export class LruMemoCache { this.missExecutionTimeMs = 0; } + /** + * Snapshot the cache as JSON so it can be persisted between processes — the + * Next.js SSR case: the server component builds the cache on one request and + * the next request hydrates it instead of re-querying the chain (#977). + * + * Expired entries are dropped, and the order is preserved (least recently + * used first) so {@link hydrate} restores the same eviction order. + * + * Values must be JSON-serializable; anything else (a `Map`, a class instance + * with private fields, a function) is silently flattened by `JSON.stringify`, + * so keep cached values to plain data. + */ + serialize(now: number = Date.now()): string { + const entries: SerializedCacheEntry[] = []; + for (const [key, value] of this.store) { + assertSerializableKey(key); + const expiresAt = this.expiresAt.get(key) ?? null; + if (expiresAt !== null && now >= expiresAt) continue; + entries.push({ key, value, expiresAt }); + } + const payload: SerializedCache = { version: 1, maxSize: this.maxSize, entries }; + return JSON.stringify(payload); + } + + /** + * Load entries produced by {@link serialize}. Expired entries are skipped and + * the cache's own `maxSize` still applies, so a snapshot taken with a larger + * limit cannot overflow this instance. + * + * Metrics are left untouched: a hydrated cache has no measured hit/miss + * timings of its own, and inventing them would make `measuredSpeedupPercent` + * a lie. + * + * @returns the number of entries actually restored. + */ + hydrate(json: string, now: number = Date.now()): number { + let payload: unknown; + try { + payload = JSON.parse(json); + } catch { + throw new SyntaxError('LruMemoCache.hydrate: input is not valid JSON'); + } + if (typeof payload !== 'object' || payload === null) { + throw new TypeError('LruMemoCache.hydrate: expected an object'); + } + const { version, entries } = payload as { version?: unknown; entries?: unknown }; + if (version !== 1) { + throw new TypeError(`LruMemoCache.hydrate: unsupported snapshot version ${String(version)}`); + } + if (!Array.isArray(entries)) { + throw new TypeError('LruMemoCache.hydrate: entries must be an array'); + } + + let restored = 0; + for (const raw of entries) { + const entry = raw as Partial> | null; + if (!entry || typeof entry !== 'object' || !('key' in entry)) continue; + assertSerializableKey(entry.key); + const expiresAt = entry.expiresAt ?? null; + if (typeof expiresAt === 'number' && now >= expiresAt) continue; + + // Insert directly so the absolute deadline survives the round trip. + this.insert(entry.key, entry.value as V, typeof expiresAt === 'number' ? expiresAt : null); + restored += 1; + } + return restored; + } + metrics(): MemoCacheMetrics { const avgHitMs = this.cacheHits > 0 ? this.hitExecutionTimeMs / this.cacheHits : null; const avgMissMs = this.cacheMisses > 0 ? this.missExecutionTimeMs / this.cacheMisses : null; @@ -109,4 +208,25 @@ export class LruMemoCache { return { cacheHits: this.cacheHits, cacheMisses: this.cacheMisses, measuredSpeedupPercent }; } -} \ No newline at end of file +} + +/** + * Persist `cache` as JSON. Thin wrapper so callers that only hold the cache + * through an interface can serialize without importing the class. + */ +export function serializeCache(cache: LruMemoCache, now: number = Date.now()): string { + return cache.serialize(now); +} + +/** + * Restore a snapshot produced by {@link serializeCache} into `cache`. + * + * @returns the number of entries restored. + */ +export function hydrateCache( + cache: LruMemoCache, + json: string, + now: number = Date.now(), +): number { + return cache.hydrate(json, now); +} diff --git a/src/tests/lru-memo-cache-serialize.test.ts b/src/tests/lru-memo-cache-serialize.test.ts new file mode 100644 index 00000000..fed0a778 --- /dev/null +++ b/src/tests/lru-memo-cache-serialize.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi } from 'vitest'; +import { LruMemoCache, serializeCache, hydrateCache } from '../lru-memo-cache.js'; + +interface Meta { + name: string; + decimals: number; +} + +const TTL = 60_000; + +describe('LruMemoCache serialize/hydrate (#977)', () => { + it('round-trips values, including their remaining TTL', () => { + const source = new LruMemoCache(10); + source.set('USDC', { name: 'USD Coin', decimals: 7 }); + source.set('XLM', { name: 'Lumen', decimals: 7 }, TTL); + + const snapshot = source.serialize(); + const restored = new LruMemoCache(10); + expect(restored.hydrate(snapshot)).toBe(2); + + expect(restored.get('USDC')).toEqual({ name: 'USD Coin', decimals: 7 }); + expect(restored.get('XLM')).toEqual({ name: 'Lumen', decimals: 7 }); + expect(restored.size).toBe(2); + }); + + it('keeps the absolute deadline instead of restarting the TTL', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const source = new LruMemoCache(5); + source.set('a', 1, 10_000); + + // Snapshot 9s later: only 1s of life is left. + vi.setSystemTime(new Date('2026-01-01T00:00:09Z')); + const snapshot = source.serialize(); + + const restored = new LruMemoCache(5); + restored.hydrate(snapshot); + + vi.setSystemTime(new Date('2026-01-01T00:00:10.5Z')); + expect(restored.get('a')).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('drops entries that already expired before the snapshot', () => { + const source = new LruMemoCache(5); + source.set('fresh', 1, TTL); + source.set('stale', 2, TTL); + + // Snapshot as if 2 minutes had passed: both are gone. + const snapshot = source.serialize(Date.now() + 120_000); + const restored = new LruMemoCache(5); + expect(restored.hydrate(snapshot)).toBe(0); + expect(restored.size).toBe(0); + }); + + it('preserves LRU order so eviction still drops the coldest entry', () => { + const source = new LruMemoCache(3); + source.set('a', 1); + source.set('b', 2); + source.set('c', 3); + source.get('a'); // 'a' becomes most recently used → order is b, c, a + + const restored = new LruMemoCache(3); + restored.hydrate(source.serialize()); + + restored.set('d', 4); // evicts 'b', the least recently used + expect(restored.get('b')).toBeUndefined(); + expect(restored.get('a')).toBe(1); + expect(restored.get('d')).toBe(4); + }); + + it('respects the destination cache capacity', () => { + const source = new LruMemoCache(10); + for (let i = 0; i < 10; i++) source.set(`k${i}`, i); + + const restored = new LruMemoCache(3); + expect(restored.hydrate(source.serialize())).toBe(10); + expect(restored.size).toBe(3); + // The newest three survive. + expect([restored.get('k7'), restored.get('k8'), restored.get('k9')]).toEqual([7, 8, 9]); + }); + + it('does not fabricate hit/miss metrics while hydrating', () => { + const source = new LruMemoCache(5); + source.set('a', 1); + const restored = new LruMemoCache(5); + restored.hydrate(source.serialize()); + + expect(restored.metrics()).toEqual({ + cacheHits: 0, + cacheMisses: 0, + measuredSpeedupPercent: null, + }); + }); + + it('rejects malformed input instead of returning a half-filled cache', () => { + const cache = new LruMemoCache(5); + expect(() => cache.hydrate('{not json')).toThrow(SyntaxError); + expect(() => cache.hydrate('[]')).toThrow(TypeError); + expect(() => cache.hydrate(JSON.stringify({ version: 99, entries: [] }))).toThrow(/version/); + expect(() => cache.hydrate(JSON.stringify({ version: 1, entries: 'nope' }))).toThrow(/entries/); + }); + + it('refuses keys that JSON would silently mangle', () => { + const cache = new LruMemoCache(5); + cache.set({ id: 1 }, 1); + expect(() => cache.serialize()).toThrow(/string\/number keys/); + }); + + it('exposes the same behaviour through the exported helpers', () => { + const source = new LruMemoCache(5); + source.set('a', 1); + const json = serializeCache(source); + + const restored = new LruMemoCache(5); + expect(hydrateCache(restored, json)).toBe(1); + expect(restored.get('a')).toBe(1); + }); + + it('produces JSON a server can pass to another request verbatim', () => { + const source = new LruMemoCache(5); + source.set('USDC', { name: 'USD Coin', decimals: 7 }, TTL); + const json = source.serialize(); + + expect(typeof json).toBe('string'); + expect(JSON.parse(json)).toMatchObject({ version: 1, maxSize: 5 }); + }); +});