Skip to content
Open
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
2 changes: 2 additions & 0 deletions cli/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 26 additions & 1 deletion cli/src/data/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -171,7 +192,8 @@ export async function fetchAndCache(
: cachedSessions.length > 0;
const cachedSessionCount = cachedSessions?.length ?? existingMeta?.sessionCount;
const headers: Record<string, string> = {};
const canRevalidate = !force && existingMeta !== null && hasExistingSessions;
const schemaOutdated = isSchemaOutdated(existingMeta);
const canRevalidate = !force && !schemaOutdated && existingMeta !== null && hasExistingSessions;

log?.(hasExistingSessions
? ` Local cache: found ${
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions cli/test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -44,6 +45,7 @@ function session(event: string, sessionCode: string = 'KEY01'): Session {
function meta(eventId: string, overrides: Partial<CacheMeta> = {}): 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',
Expand Down Expand Up @@ -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');
});
});
Loading