Merge pull request #35 from kennethnym/feat/feed-engine-cache

feat: add caching to FeedEngine
This commit is contained in:
2026-02-24 01:21:20 +00:00
committed by GitHub
3 changed files with 370 additions and 9 deletions

View File

@@ -638,4 +638,290 @@ describe("FeedEngine", () => {
) )
}) })
}) })
describe("lastFeed", () => {
test("returns null before any refresh", () => {
const engine = new FeedEngine()
expect(engine.lastFeed()).toBeNull()
})
test("returns cached result after refresh", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const engine = new FeedEngine().register(location).register(weather)
const refreshResult = await engine.refresh()
const cached = engine.lastFeed()
expect(cached).not.toBeNull()
expect(cached!.items).toEqual(refreshResult.items)
expect(cached!.context).toEqual(refreshResult.context)
})
test("returns null after TTL expires", async () => {
const engine = new FeedEngine({ cacheTtlMs: 50 })
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
engine.register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
await new Promise((resolve) => setTimeout(resolve, 60))
expect(engine.lastFeed()).toBeNull()
})
test("defaults to 5 minute TTL", async () => {
const engine = new FeedEngine()
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
engine.register(location)
await engine.refresh()
// Should still be cached immediately
expect(engine.lastFeed()).not.toBeNull()
})
test("refresh always fetches from sources", async () => {
let fetchCount = 0
const source: FeedSource = {
id: "counter",
...noActions,
async fetchContext() {
fetchCount++
return null
},
}
const engine = new FeedEngine().register(source)
await engine.refresh()
await engine.refresh()
await engine.refresh()
expect(fetchCount).toBe(3)
})
test("reactive context update refreshes cache", async () => {
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine({ cacheTtlMs: 5000 }).register(location).register(weather)
engine.start()
// Simulate location update which triggers reactive refresh
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
// Wait for async reactive refresh to complete
await new Promise((resolve) => setTimeout(resolve, 50))
const cached = engine.lastFeed()
expect(cached).not.toBeNull()
expect(cached!.items.length).toBeGreaterThan(0)
engine.stop()
})
test("reactive item update refreshes cache", async () => {
let itemUpdateCallback: (() => void) | null = null
const source: FeedSource = {
id: "reactive-items",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [
{
id: "item-1",
type: "test",
priority: 0.5,
timestamp: new Date(),
data: {},
},
]
},
onItemsUpdate(callback) {
itemUpdateCallback = callback
return () => {
itemUpdateCallback = null
}
},
}
const engine = new FeedEngine().register(source)
engine.start()
// Trigger item update
itemUpdateCallback!()
// Wait for async refresh
await new Promise((resolve) => setTimeout(resolve, 50))
const cached = engine.lastFeed()
expect(cached).not.toBeNull()
expect(cached!.items).toHaveLength(1)
engine.stop()
})
test("TTL resets after reactive update", async () => {
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
engine.start()
// Initial reactive update
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(engine.lastFeed()).not.toBeNull()
// Wait 70ms (total 120ms from first update, past original TTL)
// but trigger another update at 50ms to reset TTL
location.simulateUpdate({ lat: 52.0, lng: -0.2 })
await new Promise((resolve) => setTimeout(resolve, 50))
// Should still be cached because TTL was reset by second update
expect(engine.lastFeed()).not.toBeNull()
engine.stop()
})
test("cacheTtlMs is configurable", async () => {
const engine = new FeedEngine({ cacheTtlMs: 30 })
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
engine.register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
await new Promise((resolve) => setTimeout(resolve, 40))
expect(engine.lastFeed()).toBeNull()
})
test("auto-refreshes on TTL interval after start", async () => {
let fetchCount = 0
const source: FeedSource = {
id: "counter",
...noActions,
async fetchContext() {
fetchCount++
return null
},
async fetchItems() {
return [
{
id: `item-${fetchCount}`,
type: "test",
priority: 0.5,
timestamp: new Date(),
data: {},
},
]
},
}
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
engine.start()
// Wait for two TTL intervals to elapse
await new Promise((resolve) => setTimeout(resolve, 120))
// Should have auto-refreshed at least twice
expect(fetchCount).toBeGreaterThanOrEqual(2)
expect(engine.lastFeed()).not.toBeNull()
engine.stop()
})
test("stop cancels periodic refresh", async () => {
let fetchCount = 0
const source: FeedSource = {
id: "counter",
...noActions,
async fetchContext() {
fetchCount++
return null
},
}
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
engine.start()
engine.stop()
const countAfterStop = fetchCount
// Wait past TTL
await new Promise((resolve) => setTimeout(resolve, 80))
// No additional fetches after stop
expect(fetchCount).toBe(countAfterStop)
})
test("reactive update resets periodic refresh timer", async () => {
let fetchCount = 0
const location = createLocationSource()
const countingWeather: FeedSource<WeatherFeedItem> = {
id: "weather",
dependencies: ["location"],
...noActions,
async fetchContext(ctx) {
fetchCount++
const loc = contextValue(ctx, LocationKey)
if (!loc) return null
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
async fetchItems(ctx) {
const weather = contextValue(ctx, WeatherKey)
if (!weather) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temperature: weather.temperature, condition: weather.condition },
},
]
},
}
const engine = new FeedEngine({ cacheTtlMs: 100 })
.register(location)
.register(countingWeather)
engine.start()
// At 40ms, push a reactive update — this resets the timer
await new Promise((resolve) => setTimeout(resolve, 40))
const countBeforeUpdate = fetchCount
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
await new Promise((resolve) => setTimeout(resolve, 20))
// Reactive update triggered a fetch
expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
const countAfterUpdate = fetchCount
// At 100ms from start (60ms after reactive update), the original
// timer would have fired, but it was reset. No extra fetch yet.
await new Promise((resolve) => setTimeout(resolve, 40))
expect(fetchCount).toBe(countAfterUpdate)
engine.stop()
})
})
}) })

View File

@@ -16,6 +16,14 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
const DEFAULT_CACHE_TTL_MS = 300_000 // 5 minutes
const MIN_CACHE_TTL_MS = 10 // prevent spin from zero/negative values
export interface FeedEngineConfig {
/** Cache TTL in milliseconds. Default: 300_000 (5 minutes). Minimum: 10. */
cacheTtlMs?: number
}
interface SourceGraph { interface SourceGraph {
sources: Map<string, FeedSource> sources: Map<string, FeedSource>
sorted: FeedSource[] sorted: FeedSource[]
@@ -59,6 +67,29 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
private cleanups: Array<() => void> = [] private cleanups: Array<() => void> = []
private started = false private started = false
private readonly cacheTtlMs: number
private cachedResult: FeedResult<TItems> | null = null
private cachedAt: number | null = null
private refreshTimer: ReturnType<typeof setTimeout> | null = null
constructor(config?: FeedEngineConfig) {
this.cacheTtlMs = Math.max(config?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS, MIN_CACHE_TTL_MS)
}
/**
* Returns the cached FeedResult if available and not expired.
* Returns null if no refresh has completed or the cache TTL has elapsed.
*/
lastFeed(): FeedResult<TItems> | null {
if (this.cachedResult === null || this.cachedAt === null) {
return null
}
if (Date.now() - this.cachedAt > this.cacheTtlMs) {
return null
}
return this.cachedResult
}
/** /**
* Registers a FeedSource. Invalidates the cached graph. * Registers a FeedSource. Invalidates the cached graph.
*/ */
@@ -124,7 +155,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.context = context this.context = context
return { context, items: items as TItems[], errors } const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
this.updateCache(result)
return result
} }
/** /**
@@ -138,7 +172,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
} }
/** /**
* Starts reactive subscriptions on all sources. * Starts reactive subscriptions on all sources and begins periodic refresh.
* Sources with onContextUpdate will trigger re-computation of dependents. * Sources with onContextUpdate will trigger re-computation of dependents.
*/ */
start(): void { start(): void {
@@ -168,13 +202,16 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.cleanups.push(cleanup) this.cleanups.push(cleanup)
} }
} }
this.scheduleNextRefresh()
} }
/** /**
* Stops all reactive subscriptions. * Stops all reactive subscriptions and the periodic refresh timer.
*/ */
stop(): void { stop(): void {
this.started = false this.started = false
this.cancelScheduledRefresh()
for (const cleanup of this.cleanups) { for (const cleanup of this.cleanups) {
cleanup() cleanup()
} }
@@ -279,11 +316,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
items.sort((a, b) => b.priority - a.priority) items.sort((a, b) => b.priority - a.priority)
this.notifySubscribers({ const result: FeedResult<TItems> = {
context: this.context, context: this.context,
items: items as TItems[], items: items as TItems[],
errors, errors,
}) }
this.updateCache(result)
this.notifySubscribers(result)
} }
private collectDependents(sourceId: string, graph: SourceGraph): string[] { private collectDependents(sourceId: string, graph: SourceGraph): string[] {
@@ -307,11 +347,46 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id) return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id)
} }
private updateCache(result: FeedResult<TItems>): void {
this.cachedResult = result
this.cachedAt = Date.now()
if (this.started) {
this.scheduleNextRefresh()
}
}
private scheduleNextRefresh(): void {
this.cancelScheduledRefresh()
this.refreshTimer = setTimeout(() => {
this.refresh()
.then((result) => {
this.notifySubscribers(result)
})
.catch(() => {
// Periodic refresh errors are non-fatal; schedule next attempt
if (this.started) {
this.scheduleNextRefresh()
}
})
}, this.cacheTtlMs)
}
private cancelScheduledRefresh(): void {
if (this.refreshTimer !== null) {
clearTimeout(this.refreshTimer)
this.refreshTimer = null
}
}
private scheduleRefresh(): void { private scheduleRefresh(): void {
// Simple immediate refresh for now - could add debouncing later // Simple immediate refresh for now - could add debouncing later
this.refresh().then((result) => { this.refresh()
this.notifySubscribers(result) .then((result) => {
}) this.notifySubscribers(result)
})
.catch(() => {
// Reactive refresh errors are non-fatal
})
} }
private notifySubscribers(result: FeedResult<TItems>): void { private notifySubscribers(result: FeedResult<TItems>): void {

View File

@@ -13,7 +13,7 @@ export type { FeedItem } from "./feed"
export type { FeedSource } from "./feed-source" export type { FeedSource } from "./feed-source"
// Feed Engine // Feed Engine
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine" export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine" export { FeedEngine } from "./feed-engine"
// ============================================================================= // =============================================================================