feat(feed): signature-aware volatile merge with date-banded fetch-more
This commit is contained in:
@@ -8,11 +8,35 @@ const ts = (ep: Episode): number => {
|
||||
return t === undefined || Number.isNaN(t) ? Infinity : t
|
||||
}
|
||||
|
||||
/** PubDate stamp for identity matching — undated episodes collapse to a
|
||||
* single token so their twins match by title alone. */
|
||||
const stamp = (ep: Episode): string => {
|
||||
const t = ep.pubDate?.getTime()
|
||||
return t === undefined || Number.isNaN(t) ? "undated" : String(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content signature identifying the SAME episode across id changes. Episode
|
||||
* ids are stable (guid / enclosure-URL derived), but a feed can still change
|
||||
* an episode's id between refreshes: the one-time migration from the old
|
||||
* positional-id scheme, or a host that rotates signed enclosure URLs. title +
|
||||
* pubDate is the most stable combination that survives both — a feed
|
||||
* re-issuing an episode with the same title and date IS that episode.
|
||||
*/
|
||||
export const episodeSignature = (ep: Episode): string =>
|
||||
`${ep.title}\u0000${stamp(ep)}`
|
||||
|
||||
/**
|
||||
* Union of two episode lists keyed by id — on collision the fetched copy
|
||||
* 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.
|
||||
* wins (fresh metadata). An existing episode whose id differs from every
|
||||
* fetched id but whose content signature matches a fetched episode is a
|
||||
* stale-id twin (id migration / rotating enclosure URLs) and is dropped,
|
||||
* otherwise the union would double every episode on the first refresh after
|
||||
* the id scheme changed. Existing episodes with NO fetched twin survive
|
||||
* (volatile in-memory window). 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.
|
||||
@@ -23,8 +47,17 @@ export function mergeEpisodesBounded(
|
||||
keep: (ep: Episode, index: number) => boolean,
|
||||
): Episode[] {
|
||||
const byId = new Map<string, Episode>()
|
||||
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))
|
||||
const bySignature = new Map<string, Episode>()
|
||||
for (const ep of fetched) {
|
||||
byId.set(ep.id, ep)
|
||||
bySignature.set(episodeSignature(ep), ep)
|
||||
}
|
||||
const merged = [...byId.values()]
|
||||
for (const ep of existing) {
|
||||
if (byId.has(ep.id)) continue
|
||||
if (bySignature.has(episodeSignature(ep))) continue
|
||||
merged.push(ep)
|
||||
}
|
||||
const sorted = merged.sort((a, b) => ts(b) - ts(a))
|
||||
return sorted.filter((ep, i) => keep(ep, i))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user