feat(feed): bound feed lifecycle to 30-day window with nonblocking refresh

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.
This commit is contained in:
2026-08-12 10:13:20 -04:00
parent e09ae15e32
commit deac6081ca
23 changed files with 2226 additions and 169 deletions

View File

@@ -137,6 +137,7 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
test("a failed refresh does not wipe the feed's episodes", async () => {
const store = useFeedStore();
const savedEpisodes = servedEpisodes;
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
@@ -158,6 +159,11 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
failPath = null;
store.removeFeed(feedId);
// Restore the shared served content: with union merge semantics (volatile
// episodes survive refreshes) this feed keeps its larger in-memory window,
// so later tests must serve the same episodes they added — a shrink here
// would make the next test's "unchanged" refresh genuinely different.
servedEpisodes = savedEpisodes;
});
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
@@ -183,3 +189,44 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () =>
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
}
});
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.
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
title: `Ep ${60 - i}`,
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/chunky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
const feedId = feed!.id;
expect(store.getFeed(feedId)!.episodes.length).toBe(20); // subscribe window
// Count event-loop yields during the refresh: each parse-chunk boundary
// posts through a MessageChannel (the yield primitive in feed.ts — the
// one macrotask turn bun's fake timers do not trap, which also pins that
// the yield works under fake timers). This runs under fake timers like
// the other tests; a setTimeout-based yield would deadlock here.
const OriginalMessageChannel = globalThis.MessageChannel;
let posts = 0;
globalThis.MessageChannel = class extends OriginalMessageChannel {
constructor() {
super();
posts++;
}
};
try {
vi.advanceTimersByTime(60_000);
await store.refreshFeed(feedId);
} finally {
globalThis.MessageChannel = OriginalMessageChannel;
}
expect(posts).toBeGreaterThan(0);
expect(store.getFeed(feedId)!.episodes.length).toBe(50); // refresh window
// Leave the shared singleton as we found it (see the addedFeedIds note
// in feed-pagination.test.ts — bun runs test files in one process).
store.removeFeed(feedId);
});