From 0adfa06e1470b65b45aa003f87d858bf782ed445 Mon Sep 17 00:00:00 2001 From: Tianqi Zhang Date: Thu, 7 May 2026 16:50:30 +0800 Subject: [PATCH 1/5] Add intelligent cache revalidation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/README.md | 5 +- cli/src/commands/common.ts | 49 ++++++++-- cli/src/contracts.ts | 12 +++ cli/src/data/cache.ts | 138 ++++++++++++++++++++++++--- cli/src/output/format.ts | 8 +- cli/test/cache.test.ts | 186 +++++++++++++++++++++++++++++++++++++ 6 files changed, 374 insertions(+), 24 deletions(-) create mode 100644 cli/test/cache.test.ts diff --git a/cli/README.md b/cli/README.md index 433467d..a8799ac 100644 --- a/cli/README.md +++ b/cli/README.md @@ -77,8 +77,9 @@ Use `--event ` to filter to a single event. Without it, commands search acro ## Behavior -- **Auto-refresh**: on first search, the CLI fetches and caches automatically. No explicit `refresh` needed. -- **Cache TTL**: 24 hours. `refresh --force` bypasses. +- **Auto-refresh**: search and lookup commands are cache-first. Missing caches are fetched automatically, and existing caches are revalidated only when their next check is due. +- **Revalidation**: due caches use conditional GET (ETag/Last-Modified). A 304 response avoids downloading the catalog body; network failures fall back to stale cache. +- **Network-friendly checks**: recent checks are skipped, stable catalogs are checked less often, and failed checks use backoff with jitter to avoid request spikes. - **Disambiguation**: if a session code exists in multiple events, the CLI shows options. - **Results**: 10 by default, `--limit` to override. diff --git a/cli/src/commands/common.ts b/cli/src/commands/common.ts index b548d9a..120c1ff 100644 --- a/cli/src/commands/common.ts +++ b/cli/src/commands/common.ts @@ -1,17 +1,48 @@ import { KNOWN_EVENTS } from '../config.js'; -import { getAllCachedSessions, fetchAndCache } from '../data/cache.js'; +import { + fetchAndCache, + isCacheCheckDue, + readMeta, + readSessions, + recordFailedCheck, +} from '../data/cache.js'; +import { FetchError } from '../errors.js'; export async function ensureCache(): Promise { - const sessions = await getAllCachedSessions(); - if (sessions.length === 0) { - process.stderr.write('No cached sessions. Fetching...\n'); - for (const event of KNOWN_EVENTS) { - try { + let missingCacheHeaderPrinted = false; + + for (const event of KNOWN_EVENTS) { + const cachedSessions = await readSessions(event.id); + const meta = await readMeta(event.id); + const isMissingCache = cachedSessions.length === 0; + + if (!isMissingCache && !isCacheCheckDue(meta)) { + continue; + } + + try { + if (isMissingCache) { + if (!missingCacheHeaderPrinted) { + process.stderr.write('Fetching missing session caches...\n'); + missingCacheHeaderPrinted = true; + } process.stderr.write(` ${event.name}...`); - const fetched = await fetchAndCache(event); + } + + const fetched = await fetchAndCache(event); + if (isMissingCache) { process.stderr.write(` ${fetched.length} sessions.\n`); - } catch { - process.stderr.write(' unavailable.\n'); + } + } catch (err) { + if (!(err instanceof FetchError)) { + throw err; + } + + if (isMissingCache) { + process.stderr.write(` unavailable: ${err.message}\n`); + } else { + await recordFailedCheck(event.id); + process.stderr.write(`Could not refresh ${event.name}; using cached sessions.\n`); } } } diff --git a/cli/src/contracts.ts b/cli/src/contracts.ts index e450c1a..7a70a12 100644 --- a/cli/src/contracts.ts +++ b/cli/src/contracts.ts @@ -50,12 +50,24 @@ export interface EventConfig { endpoint: string; } +export type CacheCheckStatus = 'updated' | 'not-modified' | 'failed'; + export interface CacheMeta { eventId: string; + /** + * When session content was last downloaded and written locally. + * Kept as fetchedAt for compatibility with existing cache metadata. + */ fetchedAt: string; + /** Last time the remote catalog was checked, including 304 responses. */ + checkedAt?: string; + /** Next time search commands may revalidate this cache. */ + nextCheckAt?: string; sessionCount: number; etag?: string; lastModified?: string; + lastCheckStatus?: CacheCheckStatus; + consecutiveFailures?: number; } export interface SearchResult { diff --git a/cli/src/data/cache.ts b/cli/src/data/cache.ts index 8954311..71ca73b 100644 --- a/cli/src/data/cache.ts +++ b/cli/src/data/cache.ts @@ -2,15 +2,22 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; import envPaths from 'env-paths'; -import type { Session, CacheMeta, EventConfig } from '../contracts.js'; +import type { Session, CacheMeta, EventConfig, CacheCheckStatus } from '../contracts.js'; import { KNOWN_EVENTS } from '../config.js'; import { FetchError } from '../errors.js'; import { normalizeCatalog } from './normalize.js'; const paths = envPaths('msevents', { suffix: '' }); +const MINUTE_MS = 60 * 1000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const ACTIVE_REVALIDATION_INTERVAL_MS = 20 * MINUTE_MS; +const FAILURE_REVALIDATION_INTERVAL_MS = 15 * MINUTE_MS; +const MAX_FAILURE_REVALIDATION_INTERVAL_MS = 2 * HOUR_MS; +const JITTER_RATIO = 0.2; function cacheDir(): string { - return paths.cache; + return process.env.MSEVENTS_CACHE_DIR ?? paths.cache; } function sessionsPath(eventId: string): string { @@ -25,6 +32,64 @@ async function ensureCacheDir(): Promise { await mkdir(cacheDir(), { recursive: true }); } +function parseTime(value: string | undefined): number | null { + if (!value) return null; + const time = Date.parse(value); + return Number.isNaN(time) ? null : time; +} + +function withJitter(intervalMs: number): number { + const jitter = intervalMs * JITTER_RATIO; + const offset = (Math.random() * 2 - 1) * jitter; + return Math.max(MINUTE_MS, Math.round(intervalMs + offset)); +} + +function intervalForStableCatalog(meta: CacheMeta, now: Date): number { + const lastModified = parseTime(meta.lastModified); + if (!lastModified) return ACTIVE_REVALIDATION_INTERVAL_MS; + + const age = now.getTime() - lastModified; + if (age >= 30 * DAY_MS) return DAY_MS; + if (age >= 7 * DAY_MS) return 6 * HOUR_MS; + if (age >= DAY_MS) return 2 * HOUR_MS; + return ACTIVE_REVALIDATION_INTERVAL_MS; +} + +function nextCheckAt( + meta: CacheMeta, + status: CacheCheckStatus, + now: Date, +): string { + let interval: number; + if (status === 'failed') { + const failures = Math.max(meta.consecutiveFailures ?? 1, 1); + interval = Math.min( + FAILURE_REVALIDATION_INTERVAL_MS * (2 ** (failures - 1)), + MAX_FAILURE_REVALIDATION_INTERVAL_MS, + ); + } else { + interval = intervalForStableCatalog(meta, now); + } + + return new Date(now.getTime() + withJitter(interval)).toISOString(); +} + +export function isCacheCheckDue(meta: CacheMeta | null, now: Date = new Date()): boolean { + if (!meta) return true; + + const nextCheck = parseTime(meta.nextCheckAt); + if (nextCheck !== null) return now.getTime() >= nextCheck; + + const lastCheck = parseTime(meta.checkedAt ?? meta.fetchedAt); + if (lastCheck === null) return true; + return now.getTime() - lastCheck >= ACTIVE_REVALIDATION_INTERVAL_MS; +} + +async function writeMeta(eventId: string, meta: CacheMeta): Promise { + await ensureCacheDir(); + await writeFile(metaPath(eventId), JSON.stringify(meta, null, 2)); +} + export async function readMeta(eventId: string): Promise { const path = metaPath(eventId); if (!existsSync(path)) return null; @@ -50,10 +115,12 @@ export async function fetchAndCache(event: EventConfig, force: boolean = false): await ensureCacheDir(); const existingMeta = await readMeta(event.id); + const existingSessions = await readSessions(event.id); const headers: Record = {}; + const canRevalidate = !force && existingMeta !== null && existingSessions.length > 0; // Conditional GET if we have prior data and not forcing - if (!force && existingMeta) { + if (canRevalidate) { if (existingMeta.etag) headers['If-None-Match'] = existingMeta.etag; if (existingMeta.lastModified) headers['If-Modified-Since'] = existingMeta.lastModified; } @@ -68,10 +135,24 @@ export async function fetchAndCache(event: EventConfig, force: boolean = false): } // 304 Not Modified — cache is still fresh - if (response.status === 304 && existingMeta) { - const updatedMeta: CacheMeta = { ...existingMeta, fetchedAt: new Date().toISOString() }; - await writeFile(metaPath(event.id), JSON.stringify(updatedMeta, null, 2)); - return readSessions(event.id); + if (response.status === 304) { + if (!canRevalidate || existingMeta === null) { + throw new FetchError( + `${event.endpoint} returned 304 without a usable local cache`, + response.status, + ); + } + + const now = new Date(); + const checkedMeta: CacheMeta = { + ...existingMeta, + checkedAt: now.toISOString(), + lastCheckStatus: 'not-modified', + consecutiveFailures: 0, + }; + checkedMeta.nextCheckAt = nextCheckAt(checkedMeta, 'not-modified', now); + await writeMeta(event.id, checkedMeta); + return existingSessions; } if (!response.ok) { @@ -81,23 +162,58 @@ export async function fetchAndCache(event: EventConfig, force: boolean = false): ); } - const raw = (await response.json()) as unknown[]; + let raw: unknown; + try { + raw = await response.json(); + } catch (err) { + throw new FetchError( + `${event.endpoint} returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!Array.isArray(raw)) { + throw new FetchError(`${event.endpoint} returned an unexpected catalog shape`); + } + const sessions = normalizeCatalog(raw, event.id); + const now = new Date(); - const meta: CacheMeta = { + const metaBase: CacheMeta = { eventId: event.id, - fetchedAt: new Date().toISOString(), + fetchedAt: now.toISOString(), + checkedAt: now.toISOString(), sessionCount: sessions.length, etag: response.headers.get('etag') ?? undefined, lastModified: response.headers.get('last-modified') ?? undefined, + lastCheckStatus: 'updated', + consecutiveFailures: 0, + }; + const meta: CacheMeta = { + ...metaBase, + nextCheckAt: nextCheckAt(metaBase, 'updated', now), }; await writeFile(sessionsPath(event.id), JSON.stringify(sessions)); - await writeFile(metaPath(event.id), JSON.stringify(meta, null, 2)); + await writeMeta(event.id, meta); return sessions; } +export async function recordFailedCheck(eventId: string): Promise { + const existingMeta = await readMeta(eventId); + if (!existingMeta) return; + + const now = new Date(); + const checkedMeta: CacheMeta = { + ...existingMeta, + checkedAt: now.toISOString(), + lastCheckStatus: 'failed', + consecutiveFailures: (existingMeta.consecutiveFailures ?? 0) + 1, + }; + checkedMeta.nextCheckAt = nextCheckAt(checkedMeta, 'failed', now); + await writeMeta(eventId, checkedMeta); +} + export async function getAllCachedSessions(): Promise { await ensureCacheDir(); const all: Session[] = []; diff --git a/cli/src/output/format.ts b/cli/src/output/format.ts index bac73cb..cdca7b2 100644 --- a/cli/src/output/format.ts +++ b/cli/src/output/format.ts @@ -79,8 +79,12 @@ export function formatStatus( return statuses .map(({ eventId, meta }) => { if (!meta) return ` ${eventId}: not cached`; - const age = formatAge(Date.now() - new Date(meta.fetchedAt).getTime()); - return ` ${eventId}: ${meta.sessionCount} sessions, cached ${age}`; + const cachedAge = formatAge(Date.now() - new Date(meta.fetchedAt).getTime()); + const checkedAge = meta.checkedAt + ? `, checked ${formatAge(Date.now() - new Date(meta.checkedAt).getTime())}` + : ''; + const status = meta.lastCheckStatus === 'failed' ? ', last check failed' : ''; + return ` ${eventId}: ${meta.sessionCount} sessions, cached ${cachedAge}${checkedAge}${status}`; }) .join('\n'); } diff --git a/cli/test/cache.test.ts b/cli/test/cache.test.ts new file mode 100644 index 0000000..dffafd6 --- /dev/null +++ b/cli/test/cache.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ensureCache } from '../src/commands/common.js'; +import { getAllCachedSessions, readMeta } from '../src/data/cache.js'; +import type { CacheMeta, RawSession, Session } from '../src/contracts.js'; + +const NOW = '2026-05-07T03:00:00.000Z'; + +function session(event: string, sessionCode: string = 'KEY01'): Session { + return { + sessionCode, + title: `${event} title`, + description: '', + speakers: '', + timeSlot: '', + startDateTime: '', + endDateTime: '', + location: '', + level: '', + type: '', + topic: '', + solutionArea: '', + product: '', + languages: '', + tags: '', + relatedSessionCodes: '', + slideDeck: '', + onDemand: '', + event, + }; +} + +function meta(eventId: string, overrides: Partial = {}): CacheMeta { + return { + eventId, + fetchedAt: '2026-05-07T02:00:00.000Z', + checkedAt: '2026-05-07T02:00:00.000Z', + nextCheckAt: '2026-05-07T04:00:00.000Z', + sessionCount: 1, + etag: `"${eventId}"`, + lastModified: 'Thu, 07 May 2026 02:00:00 GMT', + lastCheckStatus: 'updated', + consecutiveFailures: 0, + ...overrides, + }; +} + +function jsonResponse(raw: RawSession[], headers: Record = {}): Response { + return new Response(JSON.stringify(raw), { + status: 200, + headers: { + 'content-type': 'application/json', + ...headers, + }, + }); +} + +describe('automatic cache revalidation', () => { + let cacheDir: string; + + async function writeCachedEvent( + eventId: string, + metaOverrides: Partial = {}, + sessionCode: string = 'KEY01', + ): Promise { + await writeFile( + join(cacheDir, `${eventId}-sessions.json`), + JSON.stringify([session(eventId, sessionCode)]), + ); + await writeFile( + join(cacheDir, `${eventId}-meta.json`), + JSON.stringify(meta(eventId, metaOverrides), null, 2), + ); + } + + beforeEach(async () => { + cacheDir = await mkdtemp(join(tmpdir(), 'msevents-cache-test-')); + process.env.MSEVENTS_CACHE_DIR = cacheDir; + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + delete process.env.MSEVENTS_CACHE_DIR; + await rm(cacheDir, { recursive: true, force: true }); + }); + + it('skips remote checks when all caches were checked recently', async () => { + await writeCachedEvent('build-2025'); + await writeCachedEvent('build-2026'); + const originalMeta = await readMeta('build-2026'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await ensureCache(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(await readMeta('build-2026')).toEqual(originalMeta); + }); + + it('uses conditional GET when a cached event is due for revalidation', async () => { + const fetchedAt = '2026-05-07T01:00:00.000Z'; + await writeCachedEvent('build-2025', { + fetchedAt, + checkedAt: '2026-05-07T01:00:00.000Z', + nextCheckAt: '2026-05-07T02:00:00.000Z', + etag: '"abc"', + lastModified: 'Thu, 07 May 2026 01:00:00 GMT', + }); + await writeCachedEvent('build-2026'); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 304 })); + vi.stubGlobal('fetch', fetchMock); + + await ensureCache(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers).toMatchObject({ + 'If-None-Match': '"abc"', + 'If-Modified-Since': 'Thu, 07 May 2026 01:00:00 GMT', + }); + + const updatedMeta = await readMeta('build-2025'); + expect(updatedMeta?.fetchedAt).toBe(fetchedAt); + expect(updatedMeta?.checkedAt).toBe(NOW); + expect(updatedMeta?.lastCheckStatus).toBe('not-modified'); + expect(updatedMeta?.consecutiveFailures).toBe(0); + expect(Date.parse(updatedMeta?.nextCheckAt ?? '')).toBeGreaterThan(Date.parse(NOW)); + }); + + it('keeps stale cache usable and backs off when revalidation fails', async () => { + await writeCachedEvent('build-2025', { + checkedAt: '2026-05-07T01:00:00.000Z', + nextCheckAt: '2026-05-07T02:00:00.000Z', + consecutiveFailures: 1, + }); + await writeCachedEvent('build-2026'); + const fetchMock = vi.fn().mockRejectedValue(new TypeError('network down')); + vi.stubGlobal('fetch', fetchMock); + + await ensureCache(); + + const sessions = await getAllCachedSessions(); + expect(sessions.some((s) => s.event === 'build-2025')).toBe(true); + + const updatedMeta = await readMeta('build-2025'); + expect(updatedMeta?.lastCheckStatus).toBe('failed'); + expect(updatedMeta?.consecutiveFailures).toBe(2); + expect(updatedMeta?.checkedAt).toBe(NOW); + expect(Date.parse(updatedMeta?.nextCheckAt ?? '')).toBeGreaterThan(Date.parse(NOW)); + }); + + it('fetches and caches missing events automatically', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse( + [{ sessionCode: 'BRK101', title: 'Build 2025 session' }], + { etag: '"2025"', 'last-modified': 'Thu, 07 May 2026 02:55:00 GMT' }, + )) + .mockResolvedValueOnce(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(); + + const sessions = await getAllCachedSessions(); + expect(sessions.map((s) => s.sessionCode).sort()).toEqual(['BRK101', 'BRK202']); + + const build2026Meta = await readMeta('build-2026'); + expect(build2026Meta?.lastCheckStatus).toBe('updated'); + expect(build2026Meta?.checkedAt).toBe(NOW); + expect(Date.parse(build2026Meta?.nextCheckAt ?? '')).toBeGreaterThan(Date.parse(NOW)); + + const cachedJson = JSON.parse( + await readFile(join(cacheDir, 'build-2026-sessions.json'), 'utf-8'), + ) as Session[]; + expect(cachedJson[0]?.event).toBe('build-2026'); + }); +}); From 6b035eca7c1dc1da25d68c514fdf1aa5a0c1d6b5 Mon Sep 17 00:00:00 2001 From: Tianqi Zhang Date: Thu, 7 May 2026 16:51:13 +0800 Subject: [PATCH 2/5] Handle structured session locations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/src/contracts.ts | 4 +++- cli/src/data/normalize.ts | 2 +- cli/test/normalize.test.ts | 13 +++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/cli/src/contracts.ts b/cli/src/contracts.ts index 7a70a12..3de4730 100644 --- a/cli/src/contracts.ts +++ b/cli/src/contracts.ts @@ -8,7 +8,9 @@ export interface RawSession { TimeSlot?: string; startDateTime?: string; endDateTime?: string; - location?: string; + location?: Array<{ displayValue?: string; logicalValue?: string } | string> + | { displayValue?: string; logicalValue?: string } + | string; sessionLevel?: Array<{ displayValue?: string; logicalValue?: string }> | string; sessionType?: { displayValue?: string; logicalValue?: string } | string; topic?: Array<{ displayValue?: string; logicalValue?: string }> | string; diff --git a/cli/src/data/normalize.ts b/cli/src/data/normalize.ts index 4700027..a3637e9 100644 --- a/cli/src/data/normalize.ts +++ b/cli/src/data/normalize.ts @@ -38,7 +38,7 @@ export function normalizeSession(raw: RawSession, eventId: string): Session | nu timeSlot: raw.TimeSlot?.trim() ?? '', startDateTime: raw.startDateTime ?? '', endDateTime: raw.endDateTime ?? '', - location: raw.location?.trim() ?? '', + location: extractDisplayValues(raw.location), level: extractDisplayValues(raw.sessionLevel), type: extractDisplayValues(raw.sessionType), topic: extractDisplayValues(raw.topic), diff --git a/cli/test/normalize.test.ts b/cli/test/normalize.test.ts index d2daee0..a872a49 100644 --- a/cli/test/normalize.test.ts +++ b/cli/test/normalize.test.ts @@ -31,6 +31,19 @@ describe('normalizeSession', () => { expect(session!.level).toContain('200'); }); + it('handles location as a displayValue object', () => { + const session = normalizeSession({ + sessionCode: 'BRK999', + title: 'Object location', + location: { + displayValue: 'Festival Pavilion', + logicalValue: 'Festival Pavilion', + }, + }, 'build-2026'); + + expect(session!.location).toBe('Festival Pavilion'); + }); + it('handles empty product arrays', () => { // Many sessions have product: [] const raw = rawSessions.find((s) => s.sessionCode === 'BRK154')!; From 425142475c62c3dbc615b3f7aea6423c3692649b Mon Sep 17 00:00:00 2001 From: Tianqi Zhang Date: Thu, 7 May 2026 17:27:27 +0800 Subject: [PATCH 3/5] Clarify refresh revalidation progress Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/README.md | 6 +-- cli/src/commands/refresh.ts | 10 +++-- cli/src/data/cache.ts | 39 ++++++++++++++++- cli/src/index.ts | 6 +-- cli/test/cache.test.ts | 84 +++++++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 10 deletions(-) diff --git a/cli/README.md b/cli/README.md index a8799ac..b0cca93 100644 --- a/cli/README.md +++ b/cli/README.md @@ -53,9 +53,9 @@ Available commands: - `--limit ` max results (default: 10) - `session ` looks up a specific session by code. Searches all cached events; disambiguates if the code appears in multiple events. - `--event ` scope to a specific event -- `refresh` downloads and caches session catalogs. - - `--event ` refresh a specific event only - - `--force` bypass cache, re-fetch unconditionally +- `refresh` checks for session catalog updates and updates the local cache. + - `--event ` check a specific event only + - `--force` bypass conditional revalidation and re-fetch unconditionally - `status` shows what's cached and how fresh it is. The `sessions` and `session` commands output human-readable text by default. Pass `--json` to get structured JSON, which is useful for piping to agents or other tools: diff --git a/cli/src/commands/refresh.ts b/cli/src/commands/refresh.ts index f755b09..3fd5f2a 100644 --- a/cli/src/commands/refresh.ts +++ b/cli/src/commands/refresh.ts @@ -16,9 +16,13 @@ export async function refresh(eventFilter?: string, force: boolean = false): Pro for (const event of events) { try { - process.stderr.write(`Fetching ${event.name}...`); - const sessions = await fetchAndCache(event, force); - process.stderr.write(` ${sessions.length} sessions cached.\n`); + process.stderr.write(`Checking ${event.name}...\n`); + await fetchAndCache(event, { + force, + log: (message) => { + process.stderr.write(message); + }, + }); } catch (err) { if (err instanceof FetchError) { process.stderr.write(` failed: ${err.message}\n`); diff --git a/cli/src/data/cache.ts b/cli/src/data/cache.ts index 71ca73b..baafcf7 100644 --- a/cli/src/data/cache.ts +++ b/cli/src/data/cache.ts @@ -16,6 +16,11 @@ const FAILURE_REVALIDATION_INTERVAL_MS = 15 * MINUTE_MS; const MAX_FAILURE_REVALIDATION_INTERVAL_MS = 2 * HOUR_MS; const JITTER_RATIO = 0.2; +export interface FetchAndCacheOptions { + force?: boolean; + log?: (message: string) => void; +} + function cacheDir(): string { return process.env.MSEVENTS_CACHE_DIR ?? paths.cache; } @@ -44,6 +49,14 @@ function withJitter(intervalMs: number): number { return Math.max(MINUTE_MS, Math.round(intervalMs + offset)); } +function formatSessionCount(count: number): string { + return `${count} session${count === 1 ? '' : 's'}`; +} + +function formatResponseStatus(response: Response): string { + return [response.status, response.statusText].filter(Boolean).join(' '); +} + function intervalForStableCatalog(meta: CacheMeta, now: Date): number { const lastModified = parseTime(meta.lastModified); if (!lastModified) return ACTIVE_REVALIDATION_INTERVAL_MS; @@ -111,20 +124,36 @@ export async function readSessions(eventId: string): Promise { } } -export async function fetchAndCache(event: EventConfig, force: boolean = false): Promise { +export async function fetchAndCache( + event: EventConfig, + options: FetchAndCacheOptions = {}, +): Promise { await ensureCacheDir(); + const { force = false, log } = options; const existingMeta = await readMeta(event.id); const existingSessions = await readSessions(event.id); const headers: Record = {}; const canRevalidate = !force && existingMeta !== null && existingSessions.length > 0; + log?.(existingSessions.length > 0 + ? ` Local cache: found ${formatSessionCount(existingSessions.length)}.\n` + : ' Local cache: missing.\n'); + // Conditional GET if we have prior data and not forcing if (canRevalidate) { if (existingMeta.etag) headers['If-None-Match'] = existingMeta.etag; if (existingMeta.lastModified) headers['If-Modified-Since'] = existingMeta.lastModified; } + if (force) { + log?.(' Remote check: full GET (--force).\n'); + } else if (canRevalidate) { + log?.(' Remote check: conditional GET.\n'); + } else { + log?.(' Remote check: GET.\n'); + } + let response: Response; try { response = await fetch(event.endpoint, { headers }); @@ -152,16 +181,23 @@ export async function fetchAndCache(event: EventConfig, force: boolean = false): }; checkedMeta.nextCheckAt = nextCheckAt(checkedMeta, 'not-modified', now); await writeMeta(event.id, checkedMeta); + log?.(' Remote catalog: not modified (304 Not Modified).\n'); + log?.(' JSON download: no.\n'); + log?.(` Local cache: up to date; using ${formatSessionCount(existingSessions.length)}.\n`); return existingSessions; } if (!response.ok) { + log?.(` Remote catalog: failed (${formatResponseStatus(response)}).\n`); throw new FetchError( `${event.endpoint} returned ${response.status}`, response.status, ); } + log?.(` Remote catalog: downloaded (${formatResponseStatus(response)}).\n`); + log?.(' JSON download: yes.\n'); + let raw: unknown; try { raw = await response.json(); @@ -195,6 +231,7 @@ export async function fetchAndCache(event: EventConfig, force: boolean = false): await writeFile(sessionsPath(event.id), JSON.stringify(sessions)); await writeMeta(event.id, meta); + log?.(` Local cache: ${existingSessions.length > 0 ? 'updated' : 'created'} with ${formatSessionCount(sessions.length)}.\n`); return sessions; } diff --git a/cli/src/index.ts b/cli/src/index.ts index 22aee1b..eed7587 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -15,9 +15,9 @@ program program .command('refresh') - .description('Fetch and cache session catalogs') - .option('--event ', 'Refresh a specific event (e.g., build-2025)') - .option('--force', 'Bypass cache and re-fetch', false) + .description('Check for session catalog updates') + .option('--event ', 'Check a specific event (e.g., build-2025)') + .option('--force', 'Bypass conditional revalidation and re-fetch', false) .action(async (opts: { event?: string; force: boolean }) => { await refresh(opts.event, opts.force); }); diff --git a/cli/test/cache.test.ts b/cli/test/cache.test.ts index dffafd6..2cb0db2 100644 --- a/cli/test/cache.test.ts +++ b/cli/test/cache.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { ensureCache } from '../src/commands/common.js'; +import { refresh } from '../src/commands/refresh.js'; import { getAllCachedSessions, readMeta } from '../src/data/cache.js'; import type { CacheMeta, RawSession, Session } from '../src/contracts.js'; @@ -50,6 +51,7 @@ function meta(eventId: string, overrides: Partial = {}): CacheMeta { function jsonResponse(raw: RawSession[], headers: Record = {}): Response { return new Response(JSON.stringify(raw), { status: 200, + statusText: 'OK', headers: { 'content-type': 'application/json', ...headers, @@ -57,6 +59,12 @@ function jsonResponse(raw: RawSession[], headers: Record = {}): }); } +function stderrOutput(): string { + return vi.mocked(process.stderr.write).mock.calls + .map(([chunk]) => String(chunk)) + .join(''); +} + describe('automatic cache revalidation', () => { let cacheDir: string; @@ -183,4 +191,80 @@ describe('automatic cache revalidation', () => { ) as Session[]; expect(cachedJson[0]?.event).toBe('build-2026'); }); + + it('reports unchanged refreshes when the remote catalog returns 304', async () => { + await writeCachedEvent('build-2026'); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 304 })); + vi.stubGlobal('fetch', fetchMock); + + await refresh('build-2026'); + + expect(stderrOutput()).toContain( + 'Checking Microsoft Build 2026...\n' + + ' Local cache: found 1 session.\n' + + ' Remote check: conditional GET.\n' + + ' Remote catalog: not modified (304 Not Modified).\n' + + ' JSON download: no.\n' + + ' Local cache: up to date; using 1 session.\n', + ); + }); + + it('reports updated refreshes when the remote catalog returns new content', async () => { + await writeCachedEvent('build-2026'); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse( + [{ sessionCode: 'BRK202', title: 'Updated Build 2026 session' }], + { etag: '"2026"', 'last-modified': 'Thu, 07 May 2026 02:56:00 GMT' }, + )); + vi.stubGlobal('fetch', fetchMock); + + await refresh('build-2026'); + + expect(stderrOutput()).toContain( + 'Checking Microsoft Build 2026...\n' + + ' Local cache: found 1 session.\n' + + ' Remote check: conditional GET.\n' + + ' Remote catalog: downloaded (200 OK).\n' + + ' JSON download: yes.\n' + + ' Local cache: updated with 1 session.\n', + ); + }); + + it('reports cached refreshes when there was no local cache', async () => { + 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 refresh('build-2026'); + + expect(stderrOutput()).toContain( + 'Checking Microsoft Build 2026...\n' + + ' Local cache: missing.\n' + + ' Remote check: GET.\n' + + ' Remote catalog: downloaded (200 OK).\n' + + ' JSON download: yes.\n' + + ' Local cache: created with 1 session.\n', + ); + }); + + it('reports forced refreshes as downloaded JSON', async () => { + await writeCachedEvent('build-2026'); + 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 refresh('build-2026', true); + + expect(stderrOutput()).toContain( + 'Checking Microsoft Build 2026...\n' + + ' Local cache: found 1 session.\n' + + ' Remote check: full GET (--force).\n' + + ' Remote catalog: downloaded (200 OK).\n' + + ' JSON download: yes.\n' + + ' Local cache: updated with 1 session.\n', + ); + }); }); From 42f7b7ca10fb68fd882468f44c209081b3aee46e Mon Sep 17 00:00:00 2001 From: Tianqi Zhang Date: Thu, 7 May 2026 17:37:50 +0800 Subject: [PATCH 4/5] Address cache revalidation review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/src/data/cache.ts | 35 +++++++++++++++++++++++++++-------- cli/src/data/normalize.ts | 28 ++++++++++++++++------------ cli/test/cache.test.ts | 33 +++++++++++++++++++++++++++++++++ cli/test/normalize.test.ts | 7 ++++++- 4 files changed, 82 insertions(+), 21 deletions(-) diff --git a/cli/src/data/cache.ts b/cli/src/data/cache.ts index baafcf7..376ff89 100644 --- a/cli/src/data/cache.ts +++ b/cli/src/data/cache.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; import envPaths from 'env-paths'; @@ -103,6 +103,15 @@ async function writeMeta(eventId: string, meta: CacheMeta): Promise { await writeFile(metaPath(eventId), JSON.stringify(meta, null, 2)); } +async function cachedSessionsTimestamp(eventId: string, fallback: Date): Promise { + try { + const stats = await stat(sessionsPath(eventId)); + return stats.mtime.toISOString(); + } catch { + return fallback.toISOString(); + } +} + export async function readMeta(eventId: string): Promise { const path = metaPath(eventId); if (!existsSync(path)) return null; @@ -238,15 +247,25 @@ export async function fetchAndCache( export async function recordFailedCheck(eventId: string): Promise { const existingMeta = await readMeta(eventId); - if (!existingMeta) return; + const existingSessions = existingMeta ? [] : await readSessions(eventId); + if (!existingMeta && existingSessions.length === 0) return; const now = new Date(); - const checkedMeta: CacheMeta = { - ...existingMeta, - checkedAt: now.toISOString(), - lastCheckStatus: 'failed', - consecutiveFailures: (existingMeta.consecutiveFailures ?? 0) + 1, - }; + const checkedMeta: CacheMeta = existingMeta + ? { + ...existingMeta, + checkedAt: now.toISOString(), + lastCheckStatus: 'failed', + consecutiveFailures: (existingMeta.consecutiveFailures ?? 0) + 1, + } + : { + eventId, + fetchedAt: await cachedSessionsTimestamp(eventId, now), + checkedAt: now.toISOString(), + sessionCount: existingSessions.length, + lastCheckStatus: 'failed', + consecutiveFailures: 1, + }; checkedMeta.nextCheckAt = nextCheckAt(checkedMeta, 'failed', now); await writeMeta(eventId, checkedMeta); } diff --git a/cli/src/data/normalize.ts b/cli/src/data/normalize.ts index a3637e9..d939841 100644 --- a/cli/src/data/normalize.ts +++ b/cli/src/data/normalize.ts @@ -1,25 +1,29 @@ import type { RawSession, Session } from '../contracts.js'; +function stringifyDisplayValue(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value.trim(); + return String(value).trim(); +} + +function extractDisplayValue(field: unknown): string { + if (!field) return ''; + if (typeof field === 'object' && field !== null && 'displayValue' in field) { + return stringifyDisplayValue((field as { displayValue?: unknown }).displayValue); + } + return stringifyDisplayValue(field); +} + // Extract displayValue from nested dict fields, handling all observed shapes function extractDisplayValues(field: unknown): string { if (!field) return ''; - if (typeof field === 'string') return field; if (Array.isArray(field)) { return field - .map((item) => { - if (typeof item === 'string') return item; - if (item && typeof item === 'object' && 'displayValue' in item) { - return (item as { displayValue?: string }).displayValue ?? ''; - } - return String(item); - }) + .map((item) => extractDisplayValue(item)) .filter(Boolean) .join(', '); } - if (typeof field === 'object' && field !== null && 'displayValue' in field) { - return (field as { displayValue?: string }).displayValue ?? ''; - } - return String(field); + return extractDisplayValue(field); } export function normalizeSession(raw: RawSession, eventId: string): Session | null { diff --git a/cli/test/cache.test.ts b/cli/test/cache.test.ts index 2cb0db2..58ec416 100644 --- a/cli/test/cache.test.ts +++ b/cli/test/cache.test.ts @@ -83,6 +83,13 @@ describe('automatic cache revalidation', () => { ); } + async function writeSessionsOnly(eventId: string, sessionCode: string = 'KEY01'): Promise { + await writeFile( + join(cacheDir, `${eventId}-sessions.json`), + JSON.stringify([session(eventId, sessionCode)]), + ); + } + beforeEach(async () => { cacheDir = await mkdtemp(join(tmpdir(), 'msevents-cache-test-')); process.env.MSEVENTS_CACHE_DIR = cacheDir; @@ -94,6 +101,7 @@ describe('automatic cache revalidation', () => { afterEach(async () => { vi.useRealTimers(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); delete process.env.MSEVENTS_CACHE_DIR; await rm(cacheDir, { recursive: true, force: true }); @@ -164,6 +172,31 @@ describe('automatic cache revalidation', () => { expect(Date.parse(updatedMeta?.nextCheckAt ?? '')).toBeGreaterThan(Date.parse(NOW)); }); + it('repairs missing metadata after failed revalidation so backoff still applies', async () => { + await writeSessionsOnly('build-2025'); + await writeCachedEvent('build-2026'); + const fetchMock = vi.fn().mockRejectedValue(new TypeError('network down')); + vi.stubGlobal('fetch', fetchMock); + + await ensureCache(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const repairedMeta = await readMeta('build-2025'); + expect(repairedMeta).toMatchObject({ + eventId: 'build-2025', + sessionCount: 1, + lastCheckStatus: 'failed', + consecutiveFailures: 1, + checkedAt: NOW, + }); + expect(Date.parse(repairedMeta?.nextCheckAt ?? '')).toBeGreaterThan(Date.parse(NOW)); + + fetchMock.mockClear(); + await ensureCache(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('fetches and caches missing events automatically', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(jsonResponse( diff --git a/cli/test/normalize.test.ts b/cli/test/normalize.test.ts index a872a49..2f25c2a 100644 --- a/cli/test/normalize.test.ts +++ b/cli/test/normalize.test.ts @@ -36,12 +36,17 @@ describe('normalizeSession', () => { sessionCode: 'BRK999', title: 'Object location', location: { - displayValue: 'Festival Pavilion', + displayValue: ' Festival Pavilion ', logicalValue: 'Festival Pavilion', }, + product: [ + { displayValue: ' Azure AI Foundry ' }, + ' GitHub ', + ], }, 'build-2026'); expect(session!.location).toBe('Festival Pavilion'); + expect(session!.product).toBe('Azure AI Foundry, GitHub'); }); it('handles empty product arrays', () => { From c174c62c947d10a5686f79487c3b32edf10e4445 Mon Sep 17 00:00:00 2001 From: Tianqi Zhang Date: Thu, 7 May 2026 17:58:35 +0800 Subject: [PATCH 5/5] Reduce cache revalidation IO Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/src/commands/common.ts | 19 +++++++++++--- cli/src/commands/session.ts | 4 +-- cli/src/commands/sessions.ts | 4 +-- cli/src/data/cache.ts | 49 ++++++++++++++++++++++++++++++------ cli/test/cache.test.ts | 36 +++++++++++++++++++++++++- 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/cli/src/commands/common.ts b/cli/src/commands/common.ts index 120c1ff..bbb850a 100644 --- a/cli/src/commands/common.ts +++ b/cli/src/commands/common.ts @@ -1,15 +1,17 @@ import { KNOWN_EVENTS } from '../config.js'; import { fetchAndCache, + getAllCachedSessions, isCacheCheckDue, readMeta, readSessions, - recordFailedCheck, } from '../data/cache.js'; +import type { Session } from '../contracts.js'; import { FetchError } from '../errors.js'; -export async function ensureCache(): Promise { +export async function ensureCache(): Promise { let missingCacheHeaderPrinted = false; + const availableSessions: Session[] = []; for (const event of KNOWN_EVENTS) { const cachedSessions = await readSessions(event.id); @@ -17,6 +19,7 @@ export async function ensureCache(): Promise { const isMissingCache = cachedSessions.length === 0; if (!isMissingCache && !isCacheCheckDue(meta)) { + availableSessions.push(...cachedSessions); continue; } @@ -29,7 +32,11 @@ export async function ensureCache(): Promise { process.stderr.write(` ${event.name}...`); } - const fetched = await fetchAndCache(event); + const fetched = await fetchAndCache(event, { + cachedMeta: meta, + cachedSessions, + }); + availableSessions.push(...fetched); if (isMissingCache) { process.stderr.write(` ${fetched.length} sessions.\n`); } @@ -41,9 +48,13 @@ export async function ensureCache(): Promise { if (isMissingCache) { process.stderr.write(` unavailable: ${err.message}\n`); } else { - await recordFailedCheck(event.id); + availableSessions.push(...cachedSessions); process.stderr.write(`Could not refresh ${event.name}; using cached sessions.\n`); } } } + + return availableSessions.length > 0 + ? availableSessions + : getAllCachedSessions(); } diff --git a/cli/src/commands/session.ts b/cli/src/commands/session.ts index 9aad327..da57657 100644 --- a/cli/src/commands/session.ts +++ b/cli/src/commands/session.ts @@ -1,4 +1,3 @@ -import { getAllCachedSessions } from '../data/cache.js'; import { buildIndex, findSession } from '../search/index.js'; import { formatSessionDetail } from '../output/format.js'; import { ensureCache } from './common.js'; @@ -7,8 +6,7 @@ export async function session( code: string, opts: { event?: string; json?: boolean }, ): Promise { - await ensureCache(); - const all = await getAllCachedSessions(); + const all = await ensureCache(); buildIndex(all); const matches = findSession(code, opts.event); diff --git a/cli/src/commands/sessions.ts b/cli/src/commands/sessions.ts index 964b60a..e1087f7 100644 --- a/cli/src/commands/sessions.ts +++ b/cli/src/commands/sessions.ts @@ -1,11 +1,9 @@ -import { getAllCachedSessions } from '../data/cache.js'; import { buildIndex, searchSessions, type SearchOptions } from '../search/index.js'; import { formatSearchResults } from '../output/format.js'; import { ensureCache } from './common.js'; export async function sessions(opts: SearchOptions & { json?: boolean }): Promise { - await ensureCache(); - const all = await getAllCachedSessions(); + const all = await ensureCache(); buildIndex(all); const results = searchSessions(opts); diff --git a/cli/src/data/cache.ts b/cli/src/data/cache.ts index 376ff89..4ad1867 100644 --- a/cli/src/data/cache.ts +++ b/cli/src/data/cache.ts @@ -19,6 +19,8 @@ const JITTER_RATIO = 0.2; export interface FetchAndCacheOptions { force?: boolean; log?: (message: string) => void; + cachedMeta?: CacheMeta | null; + cachedSessions?: Session[]; } function cacheDir(): string { @@ -133,20 +135,37 @@ export async function readSessions(eventId: string): Promise { } } +function hasCachedSessions(eventId: string): boolean { + return existsSync(sessionsPath(eventId)); +} + +async function recordFetchFailure(eventId: string): Promise { + await recordFailedCheck(eventId); +} + export async function fetchAndCache( event: EventConfig, options: FetchAndCacheOptions = {}, ): Promise { await ensureCacheDir(); - const { force = false, log } = options; - const existingMeta = await readMeta(event.id); - const existingSessions = await readSessions(event.id); + const { force = false, log, cachedSessions } = options; + const existingMeta = options.cachedMeta === undefined + ? await readMeta(event.id) + : options.cachedMeta; + const hasExistingSessions = cachedSessions === undefined + ? hasCachedSessions(event.id) + : cachedSessions.length > 0; + const cachedSessionCount = cachedSessions?.length ?? existingMeta?.sessionCount; const headers: Record = {}; - const canRevalidate = !force && existingMeta !== null && existingSessions.length > 0; - - log?.(existingSessions.length > 0 - ? ` Local cache: found ${formatSessionCount(existingSessions.length)}.\n` + const canRevalidate = !force && existingMeta !== null && hasExistingSessions; + + log?.(hasExistingSessions + ? ` Local cache: found ${ + cachedSessionCount === undefined + ? 'existing sessions' + : formatSessionCount(cachedSessionCount) + }.\n` : ' Local cache: missing.\n'); // Conditional GET if we have prior data and not forcing @@ -167,6 +186,7 @@ export async function fetchAndCache( try { response = await fetch(event.endpoint, { headers }); } catch (err) { + await recordFetchFailure(event.id); throw new FetchError( `Failed to reach ${event.endpoint}: ${err instanceof Error ? err.message : String(err)}`, ); @@ -175,6 +195,16 @@ export async function fetchAndCache( // 304 Not Modified — cache is still fresh if (response.status === 304) { if (!canRevalidate || existingMeta === null) { + await recordFetchFailure(event.id); + throw new FetchError( + `${event.endpoint} returned 304 without a usable local cache`, + response.status, + ); + } + + const existingSessions = cachedSessions ?? await readSessions(event.id); + if (existingSessions.length === 0) { + await recordFetchFailure(event.id); throw new FetchError( `${event.endpoint} returned 304 without a usable local cache`, response.status, @@ -198,6 +228,7 @@ export async function fetchAndCache( if (!response.ok) { log?.(` Remote catalog: failed (${formatResponseStatus(response)}).\n`); + await recordFetchFailure(event.id); throw new FetchError( `${event.endpoint} returned ${response.status}`, response.status, @@ -211,12 +242,14 @@ export async function fetchAndCache( try { raw = await response.json(); } catch (err) { + await recordFetchFailure(event.id); throw new FetchError( `${event.endpoint} returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`, ); } if (!Array.isArray(raw)) { + await recordFetchFailure(event.id); throw new FetchError(`${event.endpoint} returned an unexpected catalog shape`); } @@ -240,7 +273,7 @@ export async function fetchAndCache( await writeFile(sessionsPath(event.id), JSON.stringify(sessions)); await writeMeta(event.id, meta); - log?.(` Local cache: ${existingSessions.length > 0 ? 'updated' : 'created'} with ${formatSessionCount(sessions.length)}.\n`); + log?.(` Local cache: ${hasExistingSessions ? 'updated' : 'created'} with ${formatSessionCount(sessions.length)}.\n`); return sessions; } diff --git a/cli/test/cache.test.ts b/cli/test/cache.test.ts index 58ec416..9f35a35 100644 --- a/cli/test/cache.test.ts +++ b/cli/test/cache.test.ts @@ -114,10 +114,11 @@ describe('automatic cache revalidation', () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - await ensureCache(); + const sessions = await ensureCache(); expect(fetchMock).not.toHaveBeenCalled(); expect(await readMeta('build-2026')).toEqual(originalMeta); + expect(sessions.map((s) => s.event).sort()).toEqual(['build-2025', 'build-2026']); }); it('uses conditional GET when a cached event is due for revalidation', async () => { @@ -300,4 +301,37 @@ describe('automatic cache revalidation', () => { ' Local cache: updated with 1 session.\n', ); }); + + it('records failed explicit refreshes when a local cache exists', async () => { + await writeCachedEvent('build-2026'); + const fetchMock = vi.fn().mockRejectedValue(new TypeError('network down')); + vi.stubGlobal('fetch', fetchMock); + + await refresh('build-2026'); + + const updatedMeta = await readMeta('build-2026'); + expect(updatedMeta?.lastCheckStatus).toBe('failed'); + expect(updatedMeta?.consecutiveFailures).toBe(1); + expect(updatedMeta?.checkedAt).toBe(NOW); + expect(Date.parse(updatedMeta?.nextCheckAt ?? '')).toBeGreaterThan(Date.parse(NOW)); + expect(stderrOutput()).toContain('failed: Failed to reach'); + }); + + it('records failed checks when the remote returns 304 without usable metadata', async () => { + await writeSessionsOnly('build-2026'); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 304 })); + vi.stubGlobal('fetch', fetchMock); + + await refresh('build-2026'); + + const repairedMeta = await readMeta('build-2026'); + expect(repairedMeta?.lastCheckStatus).toBe('failed'); + expect(repairedMeta?.consecutiveFailures).toBe(1); + expect(repairedMeta?.checkedAt).toBe(NOW); + expect(Date.parse(repairedMeta?.nextCheckAt ?? '')).toBeGreaterThan(Date.parse(NOW)); + expect(stderrOutput()).toContain( + 'failed: https://eventtools.event.microsoft.com/build2026-prod/fallback/session-all-en-us.json ' + + 'returned 304 without a usable local cache', + ); + }); });