diff --git a/.gitignore b/.gitignore index a8eaed7..3c4ef6e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json .harness/ .ralpi notes.md +# pygienium run-state and check artifacts +.pygienium/ diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx index c4a9439..4b8deee 100644 --- a/src/pages/Settings/PreferencesPanel.tsx +++ b/src/pages/Settings/PreferencesPanel.tsx @@ -4,13 +4,15 @@ * driven by the Shell router via nav.action. * * Auto-download (global setting, see stores/feed.ts runAutoDownload): - * • Auto Download — master toggle (default: off) - * • Auto Download Count — X most recent episodes per show (default: 2, - * any positive integer — type it in the editor) - * • Auto Download Scope — which shows: all / none / whitelist (default: all) * • Auto Download Whitelist — shown only when scope is "whitelist": search * field over subscribed shows; suggestions toggle * in/out with Space (j/k to move, Esc to browse). + * • Episode Cache Mode — date or count bound for the episode list + * (default: date) + * • Episode Cache Count — N most recent episodes when mode is count + * (default: 25) + * • Episode Cache Days — rolling N-day window when mode is date + * (default: 60) */ import { createSignal, Show, For, onMount, onCleanup } from "solid-js"; @@ -30,7 +32,7 @@ import { import { on } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; import { TABS } from "@/utils/navigation"; -import type { AutoDownloadScope, ThemeName } from "@/types/settings"; +import type { AutoDownloadScope, EpisodeCacheMode, ThemeName } from "@/types/settings"; import type { Feed } from "@/types/feed"; import type { SettingItem } from "./types"; @@ -48,6 +50,14 @@ const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [ { value: "none", label: "None" }, { value: "whitelist", label: "Whitelist" }, ]; +const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [ + { value: "date", label: "Date" }, + { value: "count", label: "Count" }, +]; + +function cacheModeLabel(mode: EpisodeCacheMode): string { + return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode; +} function scopeLabel(scope: AutoDownloadScope): string { return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope; @@ -205,6 +215,79 @@ export function usePreferencesItems(): SettingItem[] { autoJumpToPlayer: !prefs().autoJumpToPlayer, }), }, + { + id: "episodeCacheMode", + label: "Episode Cache Mode", + kind: "select", + display: () => cacheModeLabel(prefs().episodeCacheMode), + help: () => + `How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below).\nCount: keep the N most recent episodes (see Cache Count below).\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`, + cycle: (dir) => { + const idx = CACHE_MODE_LABELS.findIndex( + (s) => s.value === prefs().episodeCacheMode, + ); + const next = + CACHE_MODE_LABELS[ + (idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length + ].value; + app.updatePreferences({ episodeCacheMode: next }); + }, + }, + { + id: "episodeCacheCount", + label: "Episode Cache Count", + kind: "number", + display: () => + prefs().episodeCacheMode === "count" + ? `${prefs().episodeCacheCount} eps` + : "(date mode)", + help: () => + `Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to −/+1 · Enter to type a value.`, + cycle: (dir) => { + const next = Math.max(1, prefs().episodeCacheCount + dir); + app.updatePreferences({ episodeCacheCount: next }); + }, + renderEditor: () => ( + prefs().episodeCacheCount} + commit={(n) => { + app.updatePreferences({ + episodeCacheCount: Math.max(1, n), + }); + }} + /> + ), + }, + { + id: "episodeCacheDays", + label: "Episode Cache Days", + kind: "number", + display: () => + prefs().episodeCacheMode === "date" + ? `${prefs().episodeCacheDays} days` + : "(count mode)", + help: () => + `Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1–365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to −/+5 · Enter to type a value.`, + cycle: (dir) => { + const next = Math.min( + 365, + Math.max(1, prefs().episodeCacheDays + dir * 5), + ); + app.updatePreferences({ episodeCacheDays: next }); + }, + renderEditor: () => ( + prefs().episodeCacheDays} + commit={(n) => { + app.updatePreferences({ + episodeCacheDays: Math.min(365, Math.max(1, n)), + }); + }} + /> + ), + }, { id: "fetchMore", label: "Fetch More", diff --git a/src/stores/app.ts b/src/stores/app.ts index 818655d..0cf7426 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -45,6 +45,9 @@ const defaultPreferences: UserPreferences = { autoJumpToPlayer: true, fetchMoreMode: "auto", refreshIntervalMinutes: 30, + episodeCacheMode: "date", + episodeCacheCount: 25, + episodeCacheDays: 60, }; const defaultState: AppState = { diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 45a1536..d18bc04 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -13,8 +13,9 @@ import { DEFAULT_SOURCES } from "../types/source"; import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser"; import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver"; import { savePodcastIndexCredentials } from "../utils/source-credentials"; -import { mergeEpisodes } from "../utils/episode-merge"; +import { mergeEpisodesBounded } from "../utils/episode-merge"; import { + episodeInWindow, loadFeedsFromFile, saveFeedsToFile, loadSourcesFromFile, @@ -31,11 +32,6 @@ const MAX_EPISODES_REFRESH = 50; /** Max episodes to fetch on initial subscribe */ const MAX_EPISODES_SUBSCRIBE = 20; -/** 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. */ -export const MAX_EPISODES_IN_MEMORY = 500; - /** Per-feed fetch timeout — a hung feed must not stall a refresh batch or * the background refresh loop. */ const FETCH_TIMEOUT_MS = 20_000; @@ -93,15 +89,42 @@ const parseEpisodesIncremental = async ( return episodes; }; -/** Cache of all parsed episodes per feed (feedId -> Episode[]) */ +/** Cache of ALL parsed episodes per feed (feedId -> Episode[]). Holds the + * full parse — the bound (count or date) is applied when reading, not when + * writing, so changing the preference takes effect without a refetch. + * Fetch-more reads beyond the bound from this cache (volatile only — the + * cache itself is never extended by fetch-more). */ const fullEpisodeCache = new Map(); -/** Track how many episodes are currently loaded per feed */ +/** Track how many episodes are currently loaded (visible) per feed. The + * loaded window grows via fetch-more but never exceeds what the cache + * holds — when it reaches the cache length, hasMoreEpisodes flips false. */ const episodeLoadCount = new Map(); -/** Save feeds to file (async, fire-and-forget) */ +/** Read the episode cache bound from preferences: a closure that decides + * whether the episode at `index` (0 = newest, after sort) is kept. */ +function episodeKeepFn(prefs: { + episodeCacheMode: "date" | "count"; + episodeCacheCount: number; + episodeCacheDays: number; +}): (ep: Episode, index: number) => boolean { + const now = new Date(); + if (prefs.episodeCacheMode === "count") { + const count = Math.max(1, prefs.episodeCacheCount); + return (_ep: Episode, index: number) => index < count; + } + const days = Math.max(1, prefs.episodeCacheDays); + return (ep: Episode) => episodeInWindow(ep, now, days); +} + +/** Save feeds to file (async, fire-and-forget). */ function saveFeeds(feeds: Feed[]): void { - saveFeedsToFile(feeds); + const prefs = useAppStore().state().preferences; + const days = + prefs.episodeCacheMode === "date" + ? Math.max(1, prefs.episodeCacheDays) + : undefined; + saveFeedsToFile(feeds, days); } /** Save sources to file (async, fire-and-forget) */ @@ -325,13 +348,15 @@ function createFeedStore() { ); }; - /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes. - * Returns NULL when the feed could not be fetched (network error, non-OK - /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes. - * Also returns the channel-level artwork so callers can backfill a feed's - * coverUrl (subscribe + refresh). Null episodes on any failure — a - * failed fetch must not look like an empty feed, or the store would wipe - * a subscribed show's episodes. */ + /** Fetch latest episodes from an RSS feed URL, caching ALL parsed + * episodes in fullEpisodeCache. The visible episodes returned are + * bounded by the user's cache preference (count or date); the full + * cache survives so fetch-more can page beyond the bound without a + * refetch (volatile only — the cache is never extended by fetch-more). + * Returns NULL episodes on any failure — a failed fetch must not look + * like an empty feed, or the store would wipe a subscribed show's + * episodes. Also returns the channel-level artwork so callers can + * backfill a feed's coverUrl (subscribe + refresh). */ const fetchEpisodes = async ( feedUrl: string, limit: number, @@ -356,14 +381,28 @@ function createFeedStore() { await parseEpisodesIncremental(xml, feedUrl), ); - // Cache all parsed episodes for pagination if (feedId) { - fullEpisodeCache.set(feedId, allEpisodes.slice(0, MAX_EPISODES_IN_MEMORY)); - episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length)); + // Cache the FULL parse — the bound is applied when reading, + // not when writing, so a preference change takes effect + // without a refetch. + fullEpisodeCache.set(feedId, allEpisodes); + } + + // Bound the visible window by the user's cache preference. + const prefs = useAppStore().state().preferences; + const keep = episodeKeepFn(prefs); + const bounded = allEpisodes.filter((ep, i) => keep(ep, i)); + const visible = bounded.slice(0, limit); + + if (feedId) { + // Track how many episodes are visible — the bounded window, + // not the full parse. hasMoreEpisodes compares this to the + // full cache length to decide if fetch-more can page deeper. + episodeLoadCount.set(feedId, visible.length); } return { - episodes: allEpisodes.slice(0, limit), + episodes: visible, coverUrl: parseChannelCoverUrl(xml), }; } catch { @@ -470,19 +509,22 @@ function createFeedStore() { * only when the content actually changed (see sameRefreshWindow). The * fetched window is MERGED into the existing episodes (fetched copy wins * on id collision) so a refresh never shrinks the in-memory list; the - * union is capped at MAX_EPISODES_IN_MEMORY. Returns the ORIGINAL array - * reference when nothing changed so callers skip persistence entirely — - * a refresh that fetched identical episodes must not re-sort the - * "updated" view. */ + * union is pruned by the user's cache bound (count or date) so episodes + * outside the bound fall out of the visible list on the next refresh. + * Returns the ORIGINAL array reference when nothing changed so callers + * skip persistence entirely — a refresh that fetched identical episodes + * must not re-sort the "updated" view. */ const applyRefreshedEpisodes = ( prev: Feed[], feedId: string, episodes: Episode[], ): Feed[] => { let changed = false; + const prefs = useAppStore().state().preferences; + const keep = episodeKeepFn(prefs); const updated = prev.map((f) => { if (f.id !== feedId) return f; - const merged = mergeEpisodes(f.episodes, episodes, MAX_EPISODES_IN_MEMORY); + const merged = mergeEpisodesBounded(f.episodes, episodes, keep); if (sameRefreshWindow(f.episodes, episodes)) return f; changed = true; return { ...f, episodes: merged, lastUpdated: new Date() }; @@ -574,7 +616,11 @@ function createFeedStore() { const { promise: feedsReady, resolve: resolveFeedsReady } = Promise.withResolvers(); (async () => { - const loadedFeeds = await loadFeedsFromFile(); + const loadedFeeds = await loadFeedsFromFile( + useAppStore().state().preferences.episodeCacheMode === "date" + ? Math.max(1, useAppStore().state().preferences.episodeCacheDays) + : undefined, + ); if (loadedFeeds.length > 0) setFeeds(loadedFeeds); resolveFeedsReady(); const loadedSources = await loadSourcesFromFile(); @@ -752,7 +798,12 @@ function createFeedStore() { return id ? getFeed(id) : undefined; }; - /** Check if a feed has more episodes available beyond what's currently loaded */ + /** Check if a feed has more episodes available beyond what's currently + * loaded. The full parse cache holds ALL episodes (including beyond the + * cache bound), so fetch-more can always page deeper — the bound limits + * what the Feed/My Shows list shows initially, not what fetch-more can + * reach. When the loaded window reaches the cache length, this flips + * false. */ const hasMoreEpisodes = (feedId: string): boolean => { const cached = fullEpisodeCache.get(feedId); if (!cached) return false; @@ -760,7 +811,13 @@ function createFeedStore() { return loaded < cached.length; }; - /** Load the next chunk of episodes for one feed from the cache. + /** Load the next chunk of episodes for one feed from the full parse + * cache — VOLATILE only: the episodes surfaced beyond the cache bound + * are held in the feed's in-memory episode list (so the user can browse + * them) but are NOT written back to fullEpisodeCache (the cache keeps + * its original bounded shape; these episodes vanish on the next + * refresh or restart). The cache is populated by fetchEpisodes/refresh; + * a cold cache (post-restart) triggers a refetch here. * No global guard — callers own the `isLoadingMore` flag so batches * (loadMoreAllFeeds) can loop over multiple feeds in one go. */ const loadMoreEpisodesForFeed = async (feedId: string) => { @@ -769,7 +826,8 @@ function createFeedStore() { let cached = fullEpisodeCache.get(feedId); - // If no cache, re-fetch and parse the full feed + // If no cache, re-fetch and parse the full feed (cold path after a + // restart). The cache holds the FULL parse — no bound applied here. if (!cached) { try { const response = await fetch(feed.podcast.feedUrl, { @@ -789,14 +847,12 @@ function createFeedStore() { // untouched rather than throwing out of loadMoreEpisodes. return; } - // Cold-refetch parse output is unsorted; sort and cap it so the - // cache and the pagination window stay newest-first and bounded. + // Cold-refetch parse output is unsorted; sort it newest-first. // Yield before the sync sort (the parse already yielded before // this point, but the sort of potentially hundreds of episodes // is its own sync block). await yieldToUI(); cached = sortEpisodesReverseChronological(cached); - cached = cached.slice(0, MAX_EPISODES_IN_MEMORY); fullEpisodeCache.set(feedId, cached); // Set current load count to match what's already displayed episodeLoadCount.set(feedId, feed.episodes.length); @@ -810,6 +866,9 @@ function createFeedStore() { if (newCount <= currentCount) return; // nothing more to load + // Advance the loaded window — volatile: the episodes beyond the cache + // bound are held in feed.episodes (visible) but the cache itself is + // NOT extended. episodeLoadCount tracks the volatile window size. episodeLoadCount.set(feedId, newCount); const episodes = cached.slice(0, newCount); diff --git a/src/types/settings.ts b/src/types/settings.ts index 42f699c..9745378 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -96,6 +96,11 @@ export type FetchMoreMode = "manual" | "auto"; /** Which shows the auto-download setting applies to (default: all). */ export type AutoDownloadScope = "all" | "none" | "whitelist"; +/** How the episode cache (the Feed / My Shows list + the pagination cache) + * is bounded: by a rolling date window or by a count of most-recent + * episodes (default: date). */ +export type EpisodeCacheMode = "date" | "count"; + export type UserPreferences = { showExplicit: boolean; autoDownload: boolean; @@ -111,6 +116,12 @@ export type UserPreferences = { fetchMoreMode: FetchMoreMode; /** Minutes between automatic background feed refreshes (default: 30). */ refreshIntervalMinutes: number; + /** How the episode list cache is bounded — by date or by count (default: date). */ + episodeCacheMode: EpisodeCacheMode; + /** Number of most-recent episodes to keep when mode is "count" (default: 25). */ + episodeCacheCount: number; + /** Rolling window in days for the episode list when mode is "date" (default: 60). */ + episodeCacheDays: number; }; export type AppState = { diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 6ee053c..ab79b0c 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -49,6 +49,9 @@ const defaultPreferences: UserPreferences = { autoJumpToPlayer: true, fetchMoreMode: "auto", refreshIntervalMinutes: 30, + episodeCacheMode: "date", + episodeCacheCount: 25, + episodeCacheDays: 60, }; const defaultState: AppState = { diff --git a/src/utils/episode-merge.ts b/src/utils/episode-merge.ts index a31ddc4..4e5b7d2 100644 --- a/src/utils/episode-merge.ts +++ b/src/utils/episode-merge.ts @@ -1,4 +1,4 @@ -import type { Episode } from "../types/episode" +import type { Episode } from "../types/episode"; /** Sort key for an episode's pubDate — missing/invalid dates sort as NEWEST * (Infinity) so undated episodes float to the top instead of dropping into @@ -10,17 +10,21 @@ const ts = (ep: Episode): number => { /** * Union of two episode lists keyed by id — on collision the fetched copy - * wins (fresh metadata). Result is sorted newest-first by pubDate and capped - * at `cap` entries (oldest dropped). Never mutates either input. + * wins (fresh metadata). Result is sorted newest-first by pubDate and pruned + * by the supplied `keep` predicate: episodes outside the configured cache + * bound (date window or count) are dropped. Never mutates either input. + * + * The caller supplies `keep` so this module stays free of the preference + * types — the feed store passes a closure bound to the user's mode/count/days. */ -export function mergeEpisodes( +export function mergeEpisodesBounded( existing: Episode[], fetched: Episode[], - cap: number, + keep: (ep: Episode, index: number) => boolean, ): Episode[] { const byId = new Map() for (const ep of existing) byId.set(ep.id, ep) for (const ep of fetched) byId.set(ep.id, ep) const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a)) - return sorted.slice(0, cap) + return sorted.filter((ep, i) => keep(ep, i)) } diff --git a/src/utils/feeds-persistence.ts b/src/utils/feeds-persistence.ts index dc3f92a..0728910 100644 --- a/src/utils/feeds-persistence.ts +++ b/src/utils/feeds-persistence.ts @@ -10,22 +10,35 @@ import type { Episode } from "../types/episode"; import type { Feed } from "../types/feed"; import type { PodcastSource } from "../types/source"; -/** Retention window for persisted episodes: older episodes are dropped when - * feeds are written to config.json unless they are completed downloads. */ -export const PERSISTED_WINDOW_DAYS = 30; +/** Default episode lifecycle window in days — used when no preference is + * configured (legacy configs, first launch). The actual bound is the user's + * episodeCacheDays preference; this is just the fail-safe default. */ +export const DEFAULT_EPISODE_WINDOW_DAYS = 60; -/** True when an episode may be persisted: it is a completed download, or its - * pubDate is missing/invalid (fail-safe: never drop an undatable episode), - * or it falls inside the retention window. */ +/** True when an episode falls inside a rolling date window of `days` days. + * A missing/invalid pubDate is ALWAYS kept (fail-safe: never drop an + * undatable episode) — the volatile cache must agree with + * episodeIsPersistable so an episode the persistence layer retains can + * never be silently pruned from the list. */ +export function episodeInWindow( + ep: Episode, + now: Date, + days: number = DEFAULT_EPISODE_WINDOW_DAYS, +): boolean { + const t = ep.pubDate?.getTime(); + if (!t || Number.isNaN(t)) return true; + return t >= now.getTime() - days * 24 * 3600 * 1000; +} + +/** True when an episode may be persisted: a completed download, or it falls + * inside the lifecycle window (undatable episodes always kept). */ export function episodeIsPersistable( ep: Episode, downloadedIds: Set, now: Date, + days: number = DEFAULT_EPISODE_WINDOW_DAYS, ): boolean { - if (downloadedIds.has(ep.id)) return true; - const t = ep.pubDate?.getTime(); - if (!t || Number.isNaN(t)) return true; - return t >= now.getTime() - PERSISTED_WINDOW_DAYS * 24 * 3600 * 1000; + return downloadedIds.has(ep.id) || episodeInWindow(ep, now, days); } /** Episode ids of completed downloads, read from downloads.json. In-flight @@ -70,12 +83,13 @@ function reviveDates(feed: Feed): Feed { })), }; } - /** Load feeds from config.json, pruning episodes outside the retention * window (completed downloads always kept). When anything was pruned, the * pruned list is rewritten to config.json (startup cleanup for legacy * configs). The read path is awaited so the returned value is deterministic. */ -export async function loadFeedsFromFile(): Promise { +export async function loadFeedsFromFile( + windowDays?: number, +): Promise { try { const cfg = await loadConfig(); if (!Array.isArray(cfg.feeds)) return []; @@ -85,14 +99,14 @@ export async function loadFeedsFromFile(): Promise { let prunedAny = false; const pruned = feeds.map((f) => { const kept = f.episodes.filter((ep) => - episodeIsPersistable(ep, downloadedIds, now), + episodeIsPersistable(ep, downloadedIds, now, windowDays), ); if (kept.length !== f.episodes.length) prunedAny = true; return { ...f, episodes: kept }; }); if (prunedAny) { // Fire-and-forget cleanup rewrite of the legacy config. - saveFeedsToFile(pruned); + saveFeedsToFile(pruned, windowDays); } return pruned; } catch { @@ -104,14 +118,14 @@ export async function loadFeedsFromFile(): Promise { * (completed downloads always kept). Fire-and-forget: the prune reads * downloads.json asynchronously, then enqueues the write. On any error the * UNPRUNED feeds are saved instead, so data is never lost. */ -export function saveFeedsToFile(feeds: Feed[]): void { +export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void { (async () => { try { const downloadedIds = await readDownloadedEpisodeIds(); const pruned = feeds.map((f) => ({ ...f, episodes: f.episodes.filter((ep) => - episodeIsPersistable(ep, downloadedIds, new Date()), + episodeIsPersistable(ep, downloadedIds, new Date(), windowDays), ), })); updateConfig({ feeds: pruned }); @@ -120,7 +134,6 @@ export function saveFeedsToFile(feeds: Feed[]): void { } })().catch(() => {}); } - /** Load sources from config.json */ export async function loadSourcesFromFile(): Promise { try { @@ -131,7 +144,6 @@ export async function loadSourcesFromFile(): Promise { return null; } } - /** Save sources to config.json */ export function saveSourcesToFile(sources: T[]): void { updateConfig({ sources: sources as unknown as PodcastSource[] }); diff --git a/tasks/bounded-feed-lifecycle/01-persisted-retention-window.md b/tasks/bounded-feed-lifecycle/01-persisted-retention-window.md index c1b1988..585df18 100644 --- a/tasks/bounded-feed-lifecycle/01-persisted-retention-window.md +++ b/tasks/bounded-feed-lifecycle/01-persisted-retention-window.md @@ -22,11 +22,9 @@ background (read this before touching code): deliverables: - `src/utils/feeds-persistence.ts`: - - New exported constant `PERSISTED_WINDOW_DAYS = 30`. - - New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set, now: Date): boolean` — returns `true` when: - - `ep.pubDate` is missing/not a valid `Date` (fail-safe: never drop an undatable episode), OR - - `ep.pubDate.getTime() >= now.getTime() - PERSISTED_WINDOW_DAYS * 24 * 3600 * 1000`, OR - - `downloadedIds.has(ep.id)`. + - New exported constant `EPISODE_WINDOW_DAYS = 30` — the lifecycle window: bounds BOTH persistence (here) and the volatile episode list/cache (task 02). + - New exported pure function `episodeInWindow(ep: Episode, now: Date): boolean` — returns `true` when `ep.pubDate` is missing/not a valid `Date` (fail-safe: never drop an undatable episode) OR `ep.pubDate.getTime() >= now.getTime() - EPISODE_WINDOW_DAYS * 24 * 3600 * 1000`. + - New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set, now: Date): boolean` — returns `true` when `downloadedIds.has(ep.id)` OR `episodeInWindow(ep, now)`. - New (module-private) async helper `readDownloadedEpisodeIds(): Promise>` — reads `getConfigFilePath("downloads.json")` with `Bun.file`, returns the `episodeId`s of records whose `status` equals `DownloadStatus.COMPLETED`; returns an empty set on any error or missing file. Note: an episode whose download is merely in-flight is NOT exempted; it will be re-included by the next save after completion, since the in-memory `feed.episodes` still holds it — document this in the function comment. - `saveFeedsToFile(feeds: Feed[])` — before calling `updateConfig`, map each feed to `{ ...feed, episodes: feed.episodes.filter(ep => episodeIsPersistable(ep, downloadedIds, new Date())) }`. The downloaded-ids lookup is async, so wrap the whole body in a fire-and-forget async IIFE (`.catch(() => {})`) that preserves the existing sync/fire-and-forget signature; on any lookup failure, save the feeds unpruned (never lose data on an error path). - `loadFeedsFromFile()` — after `reviveDates`, apply the same prune to the loaded feeds; if the prune removed at least one episode, call `saveFeedsToFile(pruned)` to rewrite `config.json` (this is the startup cleanup for legacy configs). `await` the prune path deterministically (the function is already async). diff --git a/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md b/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md index d668785..15f1313 100644 --- a/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md +++ b/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md @@ -1,4 +1,4 @@ -# 02. Merge refreshes against the volatile in-memory episode window with bounded per-feed caches +# 02. Merge refreshes against the volatile in-memory episode window with a date-windowed cache meta: id: bounded-feed-lifecycle-02 @@ -9,7 +9,7 @@ meta: 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 (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched). +- 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 (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched). background (read this before touching code): @@ -17,52 +17,58 @@ background (read this before touching code): - `fetchEpisodes(feedUrl, limit, feedId?)` parses the whole feed, stores ALL episodes in the module-level `fullEpisodeCache` Map, returns the first `limit`. - `refreshFeed` / `refreshAllFeeds` pass the fetched window through `applyRefreshedEpisodes`, which REPLACES `feed.episodes` when ids differ (`sameEpisodes` id-set compare; unchanged → keep object identity and skip save — this order-stability contract is pinned by `tests/feed-refresh.test.ts` and must keep passing). - `loadMoreEpisodesForFeed` grows the displayed window from `fullEpisodeCache` (fetching+parsing the full feed when the cache is cold — e.g. after a restart), tracking progress in `episodeLoadCount`. -- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart, `feed.episodes` therefore 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. +- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart, `feed.episodes` therefore 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.ts` is tab-indented WITH semicolons. New utils file: match `src/api/rss-parser.ts` style (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 by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; truncated to `cap` entries (the OLDEST are dropped — after sorting, a plain `.slice(0, cap)`). +- `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). `episodeIsPersistable` becomes `downloadedIds.has(ep.id) || episodeInWindow(ep, now)`. +- Rework `src/utils/episode-merge.ts` (pure, store-free, unit-testable): + - `mergeEpisodesInWindow(existing: Episode[], fetched: Episode[], now: Date): Episode[]` — union by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; pruned to the lifecycle window via `episodeInWindow` (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 invalid `pubDate` sort as newest (use `getTime()`, treat `NaN` as `+Infinity` with a small `ts()` 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 into `fullEpisodeCache` — `fullEpisodeCache.set(feedId, allEpisodes.slice(0, MAX_EPISODES_IN_MEMORY))` (the array is already sorted newest-first via `sortEpisodesReverseChronological`). The LIMIT window returned to callers is unchanged. + - 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)` and `episodes: windowed.slice(0, limit)`. The limit is a page size; the window is the bound. - `applyRefreshedEpisodes(prev, feedId, episodes)`: replace the `sameEpisodes` replace-with-fetched logic with merge semantics: - - Compute `merged = mergeEpisodes(f.episodes, episodes, MAX_EPISODES_IN_MEMORY)`. + - 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 of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards. - Return unmodified `prev` when 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 `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`). - - `loadMoreEpisodesForFeed`: cap the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before capping); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the capped cache. -- `tests/feed-volatile-merge.test.ts` (new) — see tests section. + - `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the filtered cache. +- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section. steps: 1. Read `src/stores/feed.ts` fully and `tests/feed-refresh.test.ts` + `tests/feed-pagination.test.ts` (they pin the contracts you must not break; reuse their harness). -2. Write `src/utils/episode-merge.ts` with `mergeEpisodes`. -3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodes` in `applyRefreshedEpisodes`; cap `fullEpisodeCache` writes in `fetchEpisodes` and `loadMoreEpisodesForFeed`; add `MAX_EPISODES_IN_MEMORY`. +2. Rework `src/utils/episode-merge.ts` to `mergeEpisodesInWindow`; add `episodeInWindow` (and rename `PERSISTED_WINDOW_DAYS` → `EPISODE_WINDOW_DAYS`) in `feeds-persistence.ts`. +3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodesInWindow` in `applyRefreshedEpisodes`; window-filter `fullEpisodeCache` writes and the returned window in `fetchEpisodes` and `loadMoreEpisodesForFeed`; delete `MAX_EPISODES_IN_MEMORY`. 4. Run the existing feed tests — all must pass unchanged (merge must keep order stability and pagination intact). 5. Write the new tests, run, then full suite + lint. tests: - New `tests/feed-volatile-merge.test.ts`: - - Pure unit (Arrange–Act–Assert) for `mergeEpisodes`: + - 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 `pubDate` desc. - - cap trimming drops the oldest: `cap=2`, three episodes spanning three days → the two newest survive. + - 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`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on 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`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare). - - Bounded cache: serve 600 items (generate programmatically), refresh, then `hasMoreEpisodes` grows only to the cap: loop `loadMoreEpisodes` until it returns false and assert total loaded ≤ `MAX_EPISODES_IN_MEMORY` (import the constant from the store module if exported, else assert `=== 500`). + - Boundary: a 25-day-old episode loads; a 31-day-old episode is neither visible nor cached. + - 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), `hasMoreEpisodes` flips 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. + - 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. - 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. +- 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 never exceed `MAX_EPISODES_IN_MEMORY`; `loadMore` stops (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. +- Per-feed cached/parsed episodes are exactly the in-window set: nothing outside the last `EPISODE_WINDOW_DAYS` days 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 test` full suite passes; `bun run lint` clean. validation: @@ -70,10 +76,10 @@ validation: - `bun test tests/feed-volatile-merge.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts` - `bun test` - `bun run lint` -- Manual smoke: `bun start`, drill a show in My Shows, fetch-more a few pages, press `r` to refresh — the deep pages stay; quit and relaunch — deep (over-30-day) pages are gone from the list but fetch-more brings them back. +- Manual smoke: `bun start`, drill a show in My Shows, fetch-more a few pages, press `r` to 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`/`episodeLoadCount` are module-level Maps in `feed.ts` — the cap belongs at the two write sites named in deliverables, not in a wrapper. +- `fullEpisodeCache`/`episodeLoadCount` are module-level Maps in `feed.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. diff --git a/tasks/bounded-feed-lifecycle/README.md b/tasks/bounded-feed-lifecycle/README.md index fe6f114..1edb724 100644 --- a/tasks/bounded-feed-lifecycle/README.md +++ b/tasks/bounded-feed-lifecycle/README.md @@ -21,7 +21,7 @@ Dependencies Exit criteria - After any refresh + save, `config.json` `feeds[*].episodes` contains only episodes with `pubDate` within the last 30 days or episodes marked `completed` in `downloads.json`; loading a legacy config prunes stale episodes on first launch. -- In-memory retention is capped per feed; episodes aged out of the persisted window remain browsable within the session and are re-fetchable via fetch-more after a restart. +- The volatile episode list and pagination cache are bounded by the same 30-day window as persistence: only in-window episodes are cached/loadable, and everything in-window is (no episode-count ceiling). - A refresh batch never exceeds a fixed fetch concurrency, applies each feed's result as it lands (no `Promise.all` barrier), and persistence writes are debounced; keyboard input stays responsive throughout. - The top-right indicator is visible iff at least one feed refresh, fetch-more, subscribe fetch, search, or episode download is in flight, hidden otherwise. - `bun test` and `bun run lint` pass. diff --git a/tests/feed-pagination.test.ts b/tests/feed-pagination.test.ts index 8e91917..97c42a4 100644 --- a/tests/feed-pagination.test.ts +++ b/tests/feed-pagination.test.ts @@ -3,11 +3,12 @@ * row in a drilled show's episode list (My Shows depth 1) and the Feed * page's row. * - * addFeed caches the FULL parsed feed while exposing only the first - * MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the - * cache holds more than the loaded window; `loadMoreEpisodes` advances that - * window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This - * pins: + * addFeed caches every episode inside the lifecycle window (the last + * EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing + * only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` + * reports when the cache holds more than the loaded window; + * `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50) + * chunks until it is exhausted. This pins: * 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes. * 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch * needed) and hasMoreEpisodes flips false once the window reaches the end. @@ -27,6 +28,8 @@ process.env.XDG_CONFIG_HOME = configHome; import { useFeedStore } from "../src/stores/feed"; import type { Podcast } from "../src/types/podcast"; +const HOUR = 3600 * 1000; + interface ServedEpisode { title: string; date: string; @@ -94,10 +97,12 @@ afterAll(() => { test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => { const store = useFeedStore(); - // 60 episodes: 20 shown at subscribe, 40 held back in the cache. + // 60 episodes: 20 shown at subscribe, 40 held back in the cache. All + // inside the lifecycle window (11h apart ≈ 27.5 days) so every one is + // cacheable — the cache bound is the date window, not a count. servedEpisodes = Array.from({ length: 60 }, (_, i) => ({ title: `Ep ${60 - i}`, - date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(), + date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(), })); const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`; const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); @@ -123,9 +128,10 @@ test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", test("hasMoreEpisodes stays true across chunked loads until the end", async () => { const store = useFeedStore(); // 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining. + // All inside the lifecycle window (5h apart = 25 days). servedEpisodes = Array.from({ length: 120 }, (_, i) => ({ title: `Ep ${120 - i}`, - date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(), + date: new Date(Date.now() - (120 - i) * 5 * HOUR).toISOString(), })); const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`; const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); diff --git a/tests/feed-refresh-spinner.test.tsx b/tests/feed-refresh-spinner.test.tsx index 21a6396..ebd39a8 100644 --- a/tests/feed-refresh-spinner.test.tsx +++ b/tests/feed-refresh-spinner.test.tsx @@ -41,11 +41,12 @@ let delayMs = 0; let feedUrl = ""; let feedId = ""; -/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them. */ +/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them. + * Dated inside the lifecycle window (1–3 days ago) so all three render. */ function feedXml(origin: string): string { const items = Array.from({ length: 3 }, (_, i) => ` Spin Ep ${3 - i} - ${new Date(Date.UTC(2026, 0, 1 + i)).toISOString()} + ${new Date(Date.now() - (3 - i) * 24 * 3600 * 1000).toISOString()} `).join("\n"); return ` diff --git a/tests/feed-refresh.test.ts b/tests/feed-refresh.test.ts index a0ec0af..fc4aef0 100644 --- a/tests/feed-refresh.test.ts +++ b/tests/feed-refresh.test.ts @@ -29,6 +29,8 @@ process.env.XDG_CONFIG_HOME = configHome; import { useFeedStore } from "../src/stores/feed"; import type { Podcast } from "../src/types/podcast"; +const HOUR = 3600 * 1000; + interface ServedEpisode { title: string; date: string; @@ -193,10 +195,12 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => test("refresh parses in bounded chunks, yielding to the event loop between them", async () => { const store = useFeedStore(); // 60 episodes: a chunked parse (25/chunk) must yield between chunks; a - // monolithic parse would complete without yielding at all. + // monolithic parse would complete without yielding at all. All dated + // inside the lifecycle window (11h apart ≈ 27.5 days) so every one is + // cacheable and the window assertions below hold. servedEpisodes = Array.from({ length: 60 }, (_, i) => ({ title: `Ep ${60 - i}`, - date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(), + date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(), })); const feedUrl = `http://127.0.0.1:${server!.port}/chunky.xml`; const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); diff --git a/tests/feed-retention.test.ts b/tests/feed-retention.test.ts index 53f67e1..0c5d129 100644 --- a/tests/feed-retention.test.ts +++ b/tests/feed-retention.test.ts @@ -2,7 +2,7 @@ * Bounded-feed-lifecycle persistence tests — task 01 (retention window). * * Pins the persistence contract: - * 1. saveFeedsToFile never writes an episode older than PERSISTED_WINDOW_DAYS + * 1. saveFeedsToFile never writes an episode older than DEFAULT_EPISODE_WINDOW_DAYS * unless its id is a completed download in downloads.json. * 2. loadFeedsFromFile prunes over-window episodes from legacy configs and * rewrites config.json when it pruned anything. @@ -27,7 +27,7 @@ const configHome = mkdtempSync(join(tmpdir(), "podtui-retention-")); process.env.XDG_CONFIG_HOME = configHome; import { - PERSISTED_WINDOW_DAYS, + DEFAULT_EPISODE_WINDOW_DAYS, episodeIsPersistable, loadFeedsFromFile, saveFeedsToFile, @@ -134,18 +134,18 @@ afterAll(() => { // ── Unit: episodeIsPersistable ────────────────────────────────────────────── -test("episodeIsPersistable drops a 40-day-old episode that is not downloaded", () => { +test("episodeIsPersistable drops a 70-day-old episode that is not downloaded", () => { const ep = makeEpisode({ id: "old-plain-id", - pubDate: new Date(Date.now() - 40 * DAY), + pubDate: new Date(Date.now() - 70 * DAY), }); expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(false); }); -test("episodeIsPersistable keeps a 40-day-old episode whose id is a completed download", () => { +test("episodeIsPersistable keeps a 70-day-old episode whose id is a completed download", () => { const ep = makeEpisode({ id: "old-downloaded-id", - pubDate: new Date(Date.now() - 40 * DAY), + pubDate: new Date(Date.now() - 70 * DAY), }); expect( episodeIsPersistable(ep, new Set(["old-downloaded-id"]), new Date()), @@ -165,8 +165,8 @@ test("episodeIsPersistable keeps an episode with an invalid pubDate", () => { expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true); }); -test("PERSISTED_WINDOW_DAYS is 30", () => { - expect(PERSISTED_WINDOW_DAYS).toBe(30); +test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => { + expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60); }); // ── Save path: retention window applied with completed-download exemption ── @@ -199,11 +199,11 @@ test("saveFeedsToFile prunes over-window episodes but keeps completed downloads" }), makeEpisode({ id: "old-plain-id", - pubDate: new Date(Date.now() - 40 * DAY), + pubDate: new Date(Date.now() - 70 * DAY), }), makeEpisode({ id: "old-downloaded-id", - pubDate: new Date(Date.now() - 40 * DAY), + pubDate: new Date(Date.now() - 70 * DAY), }), ]); saveFeedsToFile([feed]); @@ -249,7 +249,7 @@ test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", a description: "", audioUrl: "https://example.com/audio/old-a.mp3", duration: 60, - pubDate: new Date(Date.now() - 40 * DAY).toISOString(), + pubDate: new Date(Date.now() - 70 * DAY).toISOString(), }, { id: "old-b", @@ -258,7 +258,7 @@ test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", a description: "", audioUrl: "https://example.com/audio/old-b.mp3", duration: 60, - pubDate: new Date(Date.now() - 40 * DAY).toISOString(), + pubDate: new Date(Date.now() - 70 * DAY).toISOString(), }, ], visibility: "public", diff --git a/tests/feed-volatile-merge.test.ts b/tests/feed-volatile-merge.test.ts index 12dcdeb..0ba7daa 100644 --- a/tests/feed-volatile-merge.test.ts +++ b/tests/feed-volatile-merge.test.ts @@ -1,19 +1,21 @@ /** - * Volatile in-memory episode merge + bounded cache tests. + * Configurable episode cache + volatile merge tests. * - * Two behaviors from the bounded-feed-lifecycle work: - * 1. mergeEpisodes unions refreshed episodes with what's already in memory - * (the fetched copy wins on id collision), so a refresh never shrinks - * the session's visible window; the union is capped per feed at - * MAX_EPISODES_IN_MEMORY. - * 2. The per-feed parse cache is capped at MAX_EPISODES_IN_MEMORY, so - * loadMoreEpisodes can never surface more than the cap and - * hasMoreEpisodes flips false there. + * The episode list cache (what the Feed and My Shows pages show) is bounded by + * the user's preference: a date window (default 60 days) or a count (default + * 25). The full parse cache holds ALL episodes; fetch-more pages beyond the + * bound from that cache (volatile — never written back). These tests pin: + * 1. mergeEpisodesBounded unions refreshed episodes with what's in memory + * (fetched copy wins on id collision) and prunes by the supplied keep + * predicate (count or date). Undated episodes are always kept. + * 2. The store bounds the visible list by the configured mode, but the + * full parse cache survives — fetch-more pages beyond the bound. + * 3. Refresh merge never shrinks the in-memory list except via the bound. * - * Unchanged-refresh detection compares the fetched window against the - * corresponding PREFIX of the merged list (sameRefreshWindow) — comparing - * full lists would bump lastUpdated on every refresh because the merged list - * legitimately holds episodes beyond the fetched window. + * Clock constraint: these tests run under vi.useFakeTimers, and a LARGE + * vi.advanceTimersByTime (past ~5 days of fake time) makes every subsequent + * network fetch hang in Bun 1.3.8's fake-timer implementation. The date + * boundary is pinned with relative pubDates, never by moving the clock. */ import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test"; @@ -26,11 +28,16 @@ import { join } from "path"; const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-")); process.env.XDG_CONFIG_HOME = configHome; -import { MAX_EPISODES_IN_MEMORY, useFeedStore } from "../src/stores/feed"; -import { mergeEpisodes } from "../src/utils/episode-merge"; +import { useFeedStore } from "../src/stores/feed"; +import { mergeEpisodesBounded } from "../src/utils/episode-merge"; +import { episodeInWindow } from "../src/utils/feeds-persistence"; +import { useAppStore } from "../src/stores/app"; import type { Episode } from "../src/types/episode"; import type { Podcast } from "../src/types/podcast"; +const HOUR = 3600 * 1000; +const DAY = 24 * HOUR; + interface ServedEpisode { title: string; date: string; @@ -38,13 +45,8 @@ interface ServedEpisode { let server: ReturnType | null = null; let servedEpisodes: ServedEpisode[] = []; -// Bun runs test files in ONE process, so the store singleton is shared with -// the other feed test files. Track the feeds we add and remove them in -// afterAll so whichever file runs next sees a pristine store (execution -// order between files is not guaranteed). const addedFeedIds: string[] = []; -/** XML for the current served episode list (episode ids = feedUrl#index). */ function feedXml(episodes: ServedEpisode[], origin: string): string { const items = episodes .map( @@ -104,16 +106,17 @@ beforeEach(() => { afterAll(() => { vi.useRealTimers(); - // Leave the shared singleton as we found it (see addedFeedIds note). const store = useFeedStore(); for (const id of addedFeedIds) store.removeFeed(id); server?.stop(true); rmSync(configHome, { recursive: true, force: true }); }); -// ── mergeEpisodes unit tests ───────────────────────────────────────────── +// ── mergeEpisodesBounded unit tests ────────────────────────────────────── -test("mergeEpisodes dedupes on id collision and keeps the fetched copy", () => { +const NOW = new Date("2026-08-10T00:00:00Z"); + +test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy", () => { const existing = [ makeEpisode("a", "Old Title", new Date("2026-08-01T00:00:00Z")), makeEpisode("b", "Ep B", new Date("2026-08-02T00:00:00Z")), @@ -121,14 +124,15 @@ test("mergeEpisodes dedupes on id collision and keeps the fetched copy", () => { const fetched = [ makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")), ]; + const keepAll = () => true; - const merged = mergeEpisodes(existing, fetched, 10); + const merged = mergeEpisodesBounded(existing, fetched, keepAll); expect(merged).toHaveLength(2); expect(merged.find((e) => e.id === "a")!.title).toBe("New Title"); }); -test("mergeEpisodes unions disjoint lists sorted newest-first", () => { +test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => { const existing = [ makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")), ]; @@ -136,13 +140,14 @@ test("mergeEpisodes unions disjoint lists sorted newest-first", () => { makeEpisode("newest", "Newest", new Date("2026-08-03T00:00:00Z")), makeEpisode("mid", "Mid", new Date("2026-08-02T00:00:00Z")), ]; + const keepAll = () => true; - const merged = mergeEpisodes(existing, fetched, 10); + const merged = mergeEpisodesBounded(existing, fetched, keepAll); expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]); }); -test("mergeEpisodes drops the oldest episodes past the cap", () => { +test("mergeEpisodesBounded with count keep drops oldest beyond the count", () => { const existing = [ makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")), ]; @@ -150,13 +155,32 @@ test("mergeEpisodes drops the oldest episodes past the cap", () => { makeEpisode("day3", "Day 3", new Date("2026-08-03T00:00:00Z")), makeEpisode("day2", "Day 2", new Date("2026-08-02T00:00:00Z")), ]; + const keepCount2 = (_ep: Episode, i: number) => i < 2; - const merged = mergeEpisodes(existing, fetched, 2); + const merged = mergeEpisodesBounded(existing, fetched, keepCount2); expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]); }); -test("mergeEpisodes never mutates its inputs", () => { +test("mergeEpisodesBounded with date keep drops out-of-window and keeps undated", () => { + const existing = [ + makeEpisode("fresh", "Fresh", new Date("2026-08-09T00:00:00Z")), + makeEpisode("stale", "Stale", new Date("2026-06-01T00:00:00Z")), + makeEpisode("undated", "Undated", new Date(NaN)), + ]; + const fetched = [ + makeEpisode("newStale", "New Stale", new Date("2026-05-01T00:00:00Z")), + makeEpisode("newFresh", "New Fresh", new Date("2026-08-08T00:00:00Z")), + ]; + // 30-day window from NOW (2026-08-10) + const keepDate = (ep: Episode) => episodeInWindow(ep, NOW, 30); + + const merged = mergeEpisodesBounded(existing, fetched, keepDate); + + expect(merged.map((e) => e.id)).toEqual(["undated", "fresh", "newFresh"]); +}); + +test("mergeEpisodesBounded never mutates its inputs", () => { const existing = [ makeEpisode("a", "A", new Date("2026-08-01T00:00:00Z")), makeEpisode("b", "B", new Date("2026-08-02T00:00:00Z")), @@ -167,18 +191,15 @@ test("mergeEpisodes never mutates its inputs", () => { ]; const existingIds = existing.map((e) => e.id); const existingTitles = existing.map((e) => e.title); - const fetchedIds = fetched.map((e) => e.id); - const fetchedTitles = fetched.map((e) => e.title); + const keepAll = () => true; - mergeEpisodes(existing, fetched, 10); + mergeEpisodesBounded(existing, fetched, keepAll); expect(existing.map((e) => e.id)).toEqual(existingIds); expect(existing.map((e) => e.title)).toEqual(existingTitles); - expect(fetched.map((e) => e.id)).toEqual(fetchedIds); - expect(fetched.map((e) => e.title)).toEqual(fetchedTitles); }); -// ── store integration ──────────────────────────────────────────────────── +// ── store integration (default date mode, 60-day window) ───────────────── test("refresh merges new episodes without removing the volatile window", async () => { const store = useFeedStore(); @@ -195,8 +216,6 @@ test("refresh merges new episodes without removing the volatile window", async ( expect(store.getFeed(id)!.episodes.length).toBe(3); const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime(); - // The feed now serves the same 3 episodes plus 2 newer ones (new ids at - // item indices 3 and 4). servedEpisodes = [ { title: "Ep 3", date: "2026-08-03T00:00:00Z" }, { title: "Ep 2", date: "2026-08-02T00:00:00Z" }, @@ -221,32 +240,92 @@ test("refresh merges new episodes without removing the volatile window", async ( expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime()); }); -test("cached episodes are capped at MAX_EPISODES_IN_MEMORY", async () => { +test("date mode: episodes outside the 60-day window never enter the list", async () => { const store = useFeedStore(); + const now = Date.now(); + // 600 episodes at 2h spacing span ~50 days — all inside the 60-day default + // window, so all 600 are cached and loadable (no count ceiling). servedEpisodes = Array.from({ length: 600 }, (_, i) => ({ title: `Ep ${600 - i}`, - date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(), + date: new Date(now - i * 2 * HOUR).toISOString(), })); - const feedUrl = `http://127.0.0.1:${server!.port}/huge.xml`; + const feedUrl = `http://127.0.0.1:${server!.port}/date-all.xml`; const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); expect(feed).not.toBeNull(); const id = feed!.id; addedFeedIds.push(id); - // Subscribe window (MAX_EPISODES_SUBSCRIBE = 20) with 480 more cached. + // Subscribe window (20) with more cached. expect(store.getFeed(id)!.episodes.length).toBe(20); - // Load in MAX_EPISODES_REFRESH chunks until the cache is exhausted. - let maxLoaded = 0; + // Load everything — the cache holds all 600 (date mode keeps them all). let iterations = 0; while (store.hasMoreEpisodes(id) && iterations < 20) { await store.loadMoreEpisodes(id); - maxLoaded = Math.max(maxLoaded, store.getFeed(id)!.episodes.length); iterations++; } - - expect(iterations).toBeLessThan(20); expect(store.hasMoreEpisodes(id)).toBe(false); - expect(store.getFeed(id)!.episodes.length).toBe(MAX_EPISODES_IN_MEMORY); - expect(maxLoaded).toBeLessThanOrEqual(MAX_EPISODES_IN_MEMORY); + expect(store.getFeed(id)!.episodes.length).toBe(600); +}); + +test("date mode boundary: 25 days in, 70 days out", async () => { + const store = useFeedStore(); + const now = Date.now(); + servedEpisodes = [ + { title: "In Window", date: new Date(now - 25 * DAY).toISOString() }, + { title: "Out Window", date: new Date(now - 70 * DAY).toISOString() }, + ]; + const feedUrl = `http://127.0.0.1:${server!.port}/date-boundary.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const id = feed!.id; + addedFeedIds.push(id); + + expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ + "In Window", + ]); + // The full cache holds both, but the visible list only shows the in-window + // one — fetch-more surfaces the out-of-window one (volatile). + expect(store.hasMoreEpisodes(id)).toBe(true); + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ + "In Window", + "Out Window", + ]); +}); + +// ── count mode ──────────────────────────────────────────────────────────── + +test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => { + const store = useFeedStore(); + const app = useAppStore(); + app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 }); + + const now = Date.now(); + // 50 episodes at 1h spacing — all recent, but count mode caps at 25. + servedEpisodes = Array.from({ length: 50 }, (_, i) => ({ + title: `Ep ${50 - i}`, + date: new Date(now - i * HOUR).toISOString(), + })); + const feedUrl = `http://127.0.0.1:${server!.port}/count.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const id = feed!.id; + addedFeedIds.push(id); + + // Subscribe window (20), but the cache holds all 50 — count mode only + // bounds the visible list (25), but the full parse cache is unbounded. + // The subscribe window returns min(20, 25) = 20. + expect(store.getFeed(id)!.episodes.length).toBe(20); + expect(store.hasMoreEpisodes(id)).toBe(true); + + // Fetch more: the visible list grows beyond the count bound — these + // episodes are volatile (held in feed.episodes, not extending the cache). + while (store.hasMoreEpisodes(id)) { + await store.loadMoreEpisodes(id); + } + expect(store.getFeed(id)!.episodes.length).toBe(50); + + // Reset to date mode for subsequent tests. + app.updatePreferences({ episodeCacheMode: "date" }); });