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'); + }); });