Persisted feeds keep only episodes from the last 30 days (plus completed downloads); older episodes live in volatile memory and survive refreshes via union merge, with per-feed in-memory caches capped at 500. Refresh batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no Promise.all barrier), config.json writes are trailing-edge debounced (250ms, immediate flushPendingSave for unsubscribes), and cold fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store powers a global top-right indicator covering refresh, fetch-more, subscribe, search, and downloads. Also includes the in-flight incremental RSS parsing (chunked with event-loop yields) and refresh spinner work this tree already carried.
8.0 KiB
8.0 KiB
02. Merge refreshes against the volatile in-memory episode window with bounded per-feed caches
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 so memory stops growing unbounded (
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: old episodes stay browsable until the app exits, fetched refreshes never shrink the list. - Style:
feed.tsis tab-indented WITH semicolons. New utils file: matchsrc/api/rss-parser.tsstyle (2-space, no semicolons).
deliverables:
- New
src/utils/episode-merge.ts(pure, store-free, unit-testable):mergeEpisodes(existing: Episode[], fetched: Episode[], cap: number): Episode[]— union byep.id; on id collision thefetchedcopy wins (fresh metadata); result sorted bypubDatedescending; truncated tocapentries (the OLDEST are dropped — after sorting, a plain.slice(0, cap)).- Invariants: never mutates inputs; stable output for
existing=[]; entries with invalidpubDatesort as newest (usegetTime(), treatNaNas+Infinitywith a smallts()helper).
src/stores/feed.ts:- New constant
MAX_EPISODES_IN_MEMORY = 500(comment: per-feed bound on both the cached parse results and the merged in-memory window; 500 covers years of a weekly show's history while capping a 20-subscription install at 10k episodes). fetchEpisodes: cap what goes intofullEpisodeCache—fullEpisodeCache.set(feedId, allEpisodes.slice(0, MAX_EPISODES_IN_MEMORY))(the array is already sorted newest-first viasortEpisodesReverseChronological). The LIMIT window returned to callers is unchanged.applyRefreshedEpisodes(prev, feedId, episodes): replace thesameEpisodesreplace-with-fetched logic with merge semantics:- Compute
merged = mergeEpisodes(f.episodes, episodes, MAX_EPISODES_IN_MEMORY). - 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: cap the cold-refetch cache the same way afterparseEpisodesIncremental(it's unsorted there — wrap withsortEpisodesReverseChronologicalbefore capping); everything else (window growth byMAX_EPISODES_REFRESH,hasMoreEpisodescomparingepisodeLoadCount < cached.length) works unchanged against the capped cache.
- New constant
tests/feed-volatile-merge.test.ts(new) — 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). - Write
src/utils/episode-merge.tswithmergeEpisodes. - Integrate in
feed.ts: replacesameEpisodesusage withsameRefreshWindow+mergeEpisodesinapplyRefreshedEpisodes; capfullEpisodeCachewrites infetchEpisodesandloadMoreEpisodesForFeed; addMAX_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
mergeEpisodes:- 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. - cap trimming drops the oldest:
cap=2, three episodes spanning three days → the two newest survive. - 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). - Bounded cache: serve 600 items (generate programmatically), refresh, then
hasMoreEpisodesgrows only to the cap: looploadMoreEpisodesuntil it returns false and assert total loaded ≤MAX_EPISODES_IN_MEMORY(import the constant from the store module if exported, else assert=== 500).
- Refresh-keeps-volatile-window: serve 3 episodes at t0,
- 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.
- An unchanged refresh does not bump
lastUpdated(object identity of the feed is preserved). - Per-feed cached/parsed episodes never exceed
MAX_EPISODES_IN_MEMORY;loadMorestops (hasMore → false) at the cap. - After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and can surface over-30-day episodes in volatile memory.
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 deep pages stay; quit and relaunch — deep (over-30-day) pages are gone from the list but fetch-more brings them back.
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 cap 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.