10 KiB
10 KiB
02. Merge refreshes against the volatile in-memory episode window with a date-windowed cache
meta: id: bounded-feed-lifecycle-02 feature: bounded-feed-lifecycle priority: P2 depends_on: [bounded-feed-lifecycle-01] tags: [implementation, tests-required]
objective:
- Refreshing a feed must UNION the freshly fetched latest window with the episodes already in memory (instead of replacing), so episodes that task 01 pruned from disk — or deep episodes pulled in via "Fetch More" — survive refreshes within a session. Bound in-memory retention by the SAME date window persistence uses (
EPISODE_WINDOW_DAYS, 30 days) instead of an episode count: the visible list and the pagination cache hold every episode from the last 30 days, and episodes older than that age out of the list on the next refresh (fullEpisodeCachecurrently holds every parsed episode of every feed ever fetched).
background (read this before touching code):
- All work lands in
src/stores/feed.tsplus one new pure-utils module. Current behavior to change:fetchEpisodes(feedUrl, limit, feedId?)parses the whole feed, stores ALL episodes in the module-levelfullEpisodeCacheMap, returns the firstlimit.refreshFeed/refreshAllFeedspass the fetched window throughapplyRefreshedEpisodes, which REPLACESfeed.episodeswhen ids differ (sameEpisodesid-set compare; unchanged → keep object identity and skip save — this order-stability contract is pinned bytests/feed-refresh.test.tsand must keep passing).loadMoreEpisodesForFeedgrows the displayed window fromfullEpisodeCache(fetching+parsing the full feed when the cache is cold — e.g. after a restart), tracking progress inepisodeLoadCount.
- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart,
feed.episodestherefore only contains the 30-day persisted window; the full cached episode list is rebuilt lazily by the first fetch-more or refresh within the new session. This task makes the session-time behavior correct: fetched refreshes merge (never replace), and the volatile list + cache are bounded by the SAME 30-day window persistence uses — what can be browsed is exactly what can be persisted, and episodes older than the window age out on refresh. - Style:
feed.tsis tab-indented WITH semicolons. New utils file: matchsrc/api/rss-parser.tsstyle (2-space, no semicolons).
deliverables:
src/utils/feeds-persistence.ts(the canonical window owner):- Rename the retention constant to
EPISODE_WINDOW_DAYS = 30— it now bounds the volatile cache/list as well as persistence. - New exported
episodeInWindow(ep: Episode, now: Date): boolean—pubDate >= now - EPISODE_WINDOW_DAYS; missing/invalid pubDates are ALWAYS kept (fail-safe mirror of the persistence rule, so cache and disk can never disagree about an undatable episode).episodeIsPersistablebecomesdownloadedIds.has(ep.id) || episodeInWindow(ep, now).
- Rename the retention constant to
- Rework
src/utils/episode-merge.ts(pure, store-free, unit-testable):mergeEpisodesInWindow(existing: Episode[], fetched: Episode[], now: Date): Episode[]— union byep.id; on id collision thefetchedcopy wins (fresh metadata); result sorted bypubDatedescending; pruned to the lifecycle window viaepisodeInWindow(out-of-window episodes dropped, undated kept). No count cap — the bound is the date.- Invariants: never mutates inputs; stable output for
existing=[]; entries with invalidpubDatesort as newest (usegetTime(), treatNaNas+Infinitywith a smallts()helper).
src/stores/feed.ts:- Delete
MAX_EPISODES_IN_MEMORY— no episode-count bound anywhere. fetchEpisodes: window-filter the parsed feed (allEpisodes.filter(ep => episodeInWindow(ep, new Date()))) BEFORE caching and returning:fullEpisodeCache.set(feedId, windowed)andepisodes: windowed.slice(0, limit). The limit is a page size; the window is the bound.applyRefreshedEpisodes(prev, feedId, episodes): replace thesameEpisodesreplace-with-fetched logic with merge semantics:- Compute
merged = mergeEpisodesInWindow(f.episodes, episodes, new Date()). - Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small
sameRefreshWindow(existing: Episode[], fetched: Episode[])helper next to (and replacing the use of)sameEpisodes:fetched.length === 0 → true; otherwise compare id-sets offetchedandexisting.slice(0, fetched.length). Rationale: with union semanticsmergedlegitimately contains episodes beyond the fetched window, so comparing full lists would bumplastUpdatedon every refresh and resurrect the order-flapping bugtests/feed-refresh.test.tsguards. - Return unmodified
prevwhen every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set{ ...f, episodes: merged, lastUpdated: new Date() }. - Delete the now-unused
sameEpisodesif nothing else references it (grep first:grep sameEpisodes src tests).
- Compute
loadMoreEpisodesForFeed: window-filter the cold-refetch cache the same way afterparseEpisodesIncremental(it's unsorted there — wrap withsortEpisodesReverseChronologicalbefore filtering). Fetch-more stepping is mode-dependent: DATE mode advances the loaded window by aFETCH_MORE_WINDOW_DAYS(14) band past the oldest loaded episode — a daily show gains ~2 weeks of episodes per press, not a fixed count — with a +1 minimum so a sparse band can't wedge the button into a no-op; COUNT mode keeps the fixedMAX_EPISODES_REFRESH(50) chunk.hasMoreEpisodesstill comparesepisodeLoadCount < cached.length.
- Delete
tests/feed-volatile-merge.test.ts(reworked) — see tests section.
steps:
- Read
src/stores/feed.tsfully andtests/feed-refresh.test.ts+tests/feed-pagination.test.ts(they pin the contracts you must not break; reuse their harness). - Rework
src/utils/episode-merge.tstomergeEpisodesInWindow; addepisodeInWindow(and renamePERSISTED_WINDOW_DAYS→EPISODE_WINDOW_DAYS) infeeds-persistence.ts. - Integrate in
feed.ts: replacesameEpisodesusage withsameRefreshWindow+mergeEpisodesInWindowinapplyRefreshedEpisodes; window-filterfullEpisodeCachewrites and the returned window infetchEpisodesandloadMoreEpisodesForFeed; deleteMAX_EPISODES_IN_MEMORY. - Run the existing feed tests — all must pass unchanged (merge must keep order stability and pagination intact).
- Write the new tests, run, then full suite + lint.
tests:
- New
tests/feed-volatile-merge.test.ts:- Pure unit (Arrange–Act–Assert) for
mergeEpisodesInWindow(existing, fetched, now):- dedupe on collision, fetched copy wins (mutate title in the fetched twin, assert the merged entry shows the new title).
- union of disjoint lists sorted by
pubDatedesc. - window prune drops out-of-window episodes from BOTH inputs and keeps undated (NaN pubDate) episodes.
- input arrays not mutated.
- Store integration (harness per
tests/feed-refresh.test.ts: tempXDG_CONFIG_HOMEBEFORE imports,Bun.serveon port 0 serving generated RSS, fake timers):- Refresh-keeps-volatile-window: serve 3 episodes at t0,
addFeed; then serve the same 3 plus 2 new ones,refreshFeed. Assertfeed.episodes.length === 5ANDlastUpdatedadvanced AND a second identical refresh leaveslastUpdateduntouched (window-compare, not union-compare). - Boundary: a 25-day-old episode loads; a 70-day-old episode is neither visible nor cached initially, but fetch-more surfaces it (volatile).
- Date stepping: 30 episodes at 3-day spacing — each fetch-more press reveals the next 2-week band (24 → 28 → 30), NOT a fixed 50-chunk.
- Count-mode global step: two feeds with staggered dates — one Feed-page press adds the configured N most-recent UNLOADED episodes across ALL shows (N total, not N per show), via the k-way frontier merge in
loadMoreAllFeedsByCount. - Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap),
hasMoreEpisodesflips false there. - No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number.
- Refresh-keeps-volatile-window: serve 3 episodes at t0,
- Clock constraint: these tests run under fake timers, and a large
vi.advanceTimersByTime(past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it.
- Pure unit (Arrange–Act–Assert) for
- Existing suites that must keep passing:
tests/feed-refresh.test.ts,tests/feed-pagination.test.ts,tests/feed-refresh-spinner.test.tsx.
acceptance_criteria:
- A refresh never removes an episode that was visible before the refresh during the same session — except episodes that aged past the window, which drop out on refresh (the date bound).
- An unchanged refresh does not bump
lastUpdated(object identity of the feed is preserved). - Per-feed cached/parsed episodes are exactly the in-window set: nothing outside the last
EPISODE_WINDOW_DAYSdays is cached or loadable, and everything inside is (no count ceiling). - After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and applies the same window to the cache.
bun testfull suite passes;bun run lintclean.
validation:
bun test tests/feed-volatile-merge.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.tsbun testbun run lint- Manual smoke:
bun start, drill a show in My Shows, fetch-more a few pages, pressrto refresh — the in-window pages stay; quit and relaunch — the list holds only the 30-day window, and fetch-more re-parses the feed with the same window applied.
notes:
- Depends on task 01 only conceptually: without the persisted-window prune, this merge is still correct but harder to observe. If 01 isn't merged yet, the store tests still pass; the "restart keeps only 30 days" manual check requires 01.
fullEpisodeCache/episodeLoadCountare module-level Maps infeed.ts— the window filter belongs at the two write sites named in deliverables, not in a wrapper.- Do not touch persistence writes in this task; debounced save behavior is task 03. Keep calling the module-scope
saveFeeds(updated)helper exactly as today.