daily-cache: discard pre-v5 caches (fixes menubar providers regression) (#297)

PR #296 (Cursor per-project breakdown) bumped DAILY_CACHE_VERSION
from 4 to 5 but left MIN_SUPPORTED_VERSION at 2. The migration
path (isMigratableCache + migrateDays) only fills in missing
default fields; it does NOT recompute the providers / categories
/ models rollups from session data, because raw sessions are not
retained in the cache. So a v4 cache migrated to v5 carried
forward its old per-day provider totals (single 'cursor' bucket)
for the full retention window.

Effect on users post-#296: the macOS menubar's
`current.providers.cursor` would show the orphan-bucket subtotal
instead of the full Cursor cost for any historical day whose
daily entry was computed before #296 landed. Live-test on my
machine showed cursor=$3.78 against a migrated v4 cache vs
cursor=$4.08 (correct) after the daily cache was discarded — the
$0.30 gap was the workspace projects whose costs were no longer
aggregated under the 'cursor' label by the new code.

Fix: raise MIN_SUPPORTED_VERSION to 5 so any cache with
version < DAILY_CACHE_VERSION is renamed to `.bak` and the cache
is recomputed from scratch on next run. The recompute is the same
operation that backfills the cache for a new user, so the cost is
a one-time cold-path hit (~3s on the test machine).

Test for the migration case updated to assert the new
discard-and-bak behavior. Full suite: 46 files / 654 tests pass.
This commit is contained in:
Resham Joshi 2026-05-10 16:05:59 -07:00 committed by GitHub
parent 810b214476
commit d142bd97ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 23 additions and 12 deletions

View file

@ -10,10 +10,19 @@ import type { DateRange, ProjectSummary } from './types.js'
// label. After the upgrade, the breakdown produces per-workspace project
// labels for new days; without invalidation the dashboard would show
// 'cursor' for historical days and `-Users-you-myproject` for new ones
// in the same window, producing a confusing mixed projection. v5 forces a
// full recompute.
// in the same window, producing a confusing mixed projection.
export const DAILY_CACHE_VERSION = 5
const MIN_SUPPORTED_VERSION = 2
// MIN_SUPPORTED_VERSION bumped to 5 too. The migration path
// (isMigratableCache + migrateDays) only fills in missing default fields;
// it does NOT recompute the providers / categories / models rollups from
// session data, because those raw sessions are not stored in the cache.
// So a migrated v2/v3/v4 cache would carry forward stale provider totals
// (single 'cursor' bucket instead of per-workspace) for the full cache
// retention window. Setting the floor to 5 forces those older caches to
// be discarded and recomputed cleanly. Confirmed by live test:
// menubar-json --period all reported cursor=$3.78 against a migrated
// v4 cache but $4.08 (correct) after the cache was discarded.
const MIN_SUPPORTED_VERSION = 5
const DAILY_CACHE_FILENAME = 'daily-cache.json'
export type DailyEntry = {

View file

@ -77,7 +77,13 @@ describe('loadDailyCache', () => {
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(true)
})
it('migrates an older supported version by filling missing fields', async () => {
it('discards a v2 cache and starts fresh (provider rollups would be stale)', async () => {
// MIN_SUPPORTED_VERSION was raised to DAILY_CACHE_VERSION because the
// migration path cannot recompute the providers / categories / models
// rollups from session data (the cache does not retain raw sessions),
// so a migrated old cache would carry forward stale provider totals
// for the full retention window. Older caches now get discarded and
// recomputed from scratch on next run.
const saved = {
version: 2,
lastComputedDate: '2026-04-10',
@ -92,14 +98,10 @@ describe('loadDailyCache', () => {
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
const cache = await loadDailyCache()
expect(cache.version).toBe(DAILY_CACHE_VERSION)
expect(cache.days).toHaveLength(1)
expect(cache.days[0].date).toBe('2026-04-10')
expect(cache.days[0].cost).toBe(10)
expect(cache.days[0].editTurns).toBe(0)
expect(cache.days[0].oneShotTurns).toBe(0)
expect(cache.days[0].categories).toEqual({})
expect(cache.days[0].providers).toEqual({})
expect(cache.days[0].models['claude-opus-4-6'].calls).toBe(5)
expect(cache.days).toEqual([])
expect(cache.lastComputedDate).toBeNull()
// Old cache is renamed to .v2.bak rather than deleted.
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(true)
})
it('round-trips a valid cache through save and load', async () => {