From 2a81eca74d610eeb22b540a9d1c05f6368024f13 Mon Sep 17 00:00:00 2001 From: Tianqi Zhang Date: Wed, 3 Jun 2026 17:12:20 +0800 Subject: [PATCH] Add cache schema versioning to invalidate stale caches on Session shape changes When the Session interface gains or removes fields, old cached session JSON files lack the new data. Previously this caused stale fields to silently show incorrect values (e.g., hasLiveStream defaulting to false). This adds a CACHE_SCHEMA_VERSION constant (currently 2) stored in cache metadata. On cache read, if the stored version doesn't match the current constant, the CLI does a full GET (bypassing conditional 304) so that normalizeSession() can re-populate all fields from the raw catalog. Key behaviors: - Legacy caches (no schemaVersion) are treated as outdated - Schema mismatch forces full GET, not conditional revalidation - Failure backoff is still respected even when schema is outdated - After successful re-fetch, schemaVersion is written to metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/src/contracts.ts | 2 ++ cli/src/data/cache.ts | 27 +++++++++++++++- cli/test/cache.test.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/cli/src/contracts.ts b/cli/src/contracts.ts index 14dc9f9..be767b9 100644 --- a/cli/src/contracts.ts +++ b/cli/src/contracts.ts @@ -64,6 +64,8 @@ export type CacheCheckStatus = 'updated' | 'not-modified' | 'failed'; export interface CacheMeta { eventId: string; + /** Cache schema version — triggers a full re-fetch when it doesn't match the CLI's current version. */ + schemaVersion?: number; /** * When session content was last downloaded and written locally. * Kept as fetchedAt for compatibility with existing cache metadata. diff --git a/cli/src/data/cache.ts b/cli/src/data/cache.ts index 83844c9..622f424 100644 --- a/cli/src/data/cache.ts +++ b/cli/src/data/cache.ts @@ -18,6 +18,18 @@ const FAILURE_REVALIDATION_INTERVAL_MS = 15 * MINUTE_MS; const MAX_FAILURE_REVALIDATION_INTERVAL_MS = 2 * HOUR_MS; const JITTER_RATIO = 0.2; +/** + * Bump this when the Session interface shape changes (new/removed/renamed fields). + * A mismatch between this value and the stored schemaVersion forces a full re-fetch + * so that normalizeSession() can populate the new fields from the raw catalog. + */ +export const CACHE_SCHEMA_VERSION = 2; + +export function isSchemaOutdated(meta: CacheMeta | null): boolean { + if (!meta) return false; + return meta.schemaVersion !== CACHE_SCHEMA_VERSION; +} + export interface FetchAndCacheOptions { force?: boolean; log?: (message: string) => void; @@ -95,6 +107,15 @@ export function isCacheCheckDue(meta: CacheMeta | null, now: Date = new Date()): if (!meta) return true; const nextCheck = parseTime(meta.nextCheckAt); + + // Schema outdated → re-fetch needed, but respect failure backoff + if (isSchemaOutdated(meta)) { + if (meta.lastCheckStatus === 'failed' && nextCheck !== null && now.getTime() < nextCheck) { + return false; + } + return true; + } + if (nextCheck !== null) return now.getTime() >= nextCheck; const lastCheck = parseTime(meta.checkedAt ?? meta.fetchedAt); @@ -171,7 +192,8 @@ export async function fetchAndCache( : cachedSessions.length > 0; const cachedSessionCount = cachedSessions?.length ?? existingMeta?.sessionCount; const headers: Record = {}; - const canRevalidate = !force && existingMeta !== null && hasExistingSessions; + const schemaOutdated = isSchemaOutdated(existingMeta); + const canRevalidate = !force && !schemaOutdated && existingMeta !== null && hasExistingSessions; log?.(hasExistingSessions ? ` Local cache: found ${ @@ -189,6 +211,8 @@ export async function fetchAndCache( if (force) { log?.(' Remote check: full GET (--force).\n'); + } else if (schemaOutdated) { + log?.(' Remote check: full GET (cache schema outdated).\n'); } else if (canRevalidate) { log?.(' Remote check: conditional GET.\n'); } else { @@ -276,6 +300,7 @@ export async function fetchAndCache( const metaBase: CacheMeta = { eventId: event.id, + schemaVersion: CACHE_SCHEMA_VERSION, fetchedAt: now.toISOString(), checkedAt: now.toISOString(), sessionCount: sessions.length, diff --git a/cli/test/cache.test.ts b/cli/test/cache.test.ts index ddb9ed9..6756ee8 100644 --- a/cli/test/cache.test.ts +++ b/cli/test/cache.test.ts @@ -8,6 +8,7 @@ import { refresh } from '../src/commands/refresh.js'; import { getAllCachedSessions, readMeta, + CACHE_SCHEMA_VERSION, } from '../src/data/cache.js'; import type { CacheMeta, RawSession, Session } from '../src/contracts.js'; @@ -44,6 +45,7 @@ function session(event: string, sessionCode: string = 'KEY01'): Session { function meta(eventId: string, overrides: Partial = {}): CacheMeta { return { eventId, + schemaVersion: CACHE_SCHEMA_VERSION, fetchedAt: '2026-05-07T02:00:00.000Z', checkedAt: '2026-05-07T02:00:00.000Z', nextCheckAt: '2026-05-07T04:00:00.000Z', @@ -505,4 +507,73 @@ describe('automatic cache revalidation', () => { const updatedMeta = await readMeta('build-2026'); expect(updatedMeta?.lastCheckStatus).toBe('failed'); }); + + it('forces a full GET when cache schema is outdated', async () => { + await writeCachedEvent('build-2025', { schemaVersion: 1 }); + await writeCachedEvent('ignite-2025'); + await writeCachedEvent('build-2026'); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse( + [{ sessionCode: 'BRK101', title: 'Build 2025 session' }], + { etag: '"2025-new"', 'last-modified': 'Thu, 07 May 2026 02:55:00 GMT' }, + )); + vi.stubGlobal('fetch', fetchMock); + + await ensureCache(); + + // Should have fetched only build-2025 (outdated schema), not the others + expect(fetchMock).toHaveBeenCalledTimes(1); + // Should NOT send conditional headers (full GET, not revalidation) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers).not.toHaveProperty('If-None-Match'); + expect(init.headers).not.toHaveProperty('If-Modified-Since'); + + const updatedMeta = await readMeta('build-2025'); + expect(updatedMeta?.schemaVersion).toBe(CACHE_SCHEMA_VERSION); + expect(updatedMeta?.lastCheckStatus).toBe('updated'); + }); + + it('forces a full GET when cache has no schema version (legacy cache)', async () => { + await writeCachedEvent('build-2026', { schemaVersion: undefined }); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse( + [{ sessionCode: 'BRK202', title: 'Build 2026 session' }], + { etag: '"2026"', 'last-modified': 'Thu, 07 May 2026 02:56:00 GMT' }, + )); + vi.stubGlobal('fetch', fetchMock); + + await ensureCache('build-2026'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers).not.toHaveProperty('If-None-Match'); + + const updatedMeta = await readMeta('build-2026'); + expect(updatedMeta?.schemaVersion).toBe(CACHE_SCHEMA_VERSION); + }); + + it('skips fetch when cache schema version is current', async () => { + await writeCachedEvent('build-2025', { schemaVersion: CACHE_SCHEMA_VERSION }); + await writeCachedEvent('ignite-2025', { schemaVersion: CACHE_SCHEMA_VERSION }); + await writeCachedEvent('build-2026', { schemaVersion: CACHE_SCHEMA_VERSION }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await ensureCache(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('falls back to stale cache when schema is outdated but fetch fails', async () => { + await writeCachedEvent('build-2026', { + schemaVersion: 1, + checkedAt: '2026-05-07T01:00:00.000Z', + nextCheckAt: '2026-05-07T02:00:00.000Z', + }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down'))); + + const sessions = await ensureCache('build-2026'); + + // Should fall back to stale cache even though schema is outdated + expect(sessions).toHaveLength(1); + expect(sessions[0]?.event).toBe('build-2026'); + }); });