diff --git a/packages/aris-core/src/feed-engine.test.ts b/packages/aris-core/src/feed-engine.test.ts index 6dd6435..1c4c867 100644 --- a/packages/aris-core/src/feed-engine.test.ts +++ b/packages/aris-core/src/feed-engine.test.ts @@ -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 = { + 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() + }) + }) }) diff --git a/packages/aris-core/src/feed-engine.ts b/packages/aris-core/src/feed-engine.ts index a55e34e..c2ab866 100644 --- a/packages/aris-core/src/feed-engine.ts +++ b/packages/aris-core/src/feed-engine.ts @@ -16,6 +16,14 @@ export interface FeedResult { export type FeedSubscriber = (result: FeedResult) => 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 { sources: Map sorted: FeedSource[] @@ -59,6 +67,29 @@ export class FeedEngine { private cleanups: Array<() => void> = [] private started = false + private readonly cacheTtlMs: number + private cachedResult: FeedResult | null = null + private cachedAt: number | null = null + private refreshTimer: ReturnType | 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 | 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. */ @@ -124,7 +155,10 @@ export class FeedEngine { this.context = context - return { context, items: items as TItems[], errors } + const result: FeedResult = { context, items: items as TItems[], errors } + this.updateCache(result) + + return result } /** @@ -138,7 +172,7 @@ export class FeedEngine { } /** - * 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. */ start(): void { @@ -168,13 +202,16 @@ export class FeedEngine { this.cleanups.push(cleanup) } } + + this.scheduleNextRefresh() } /** - * Stops all reactive subscriptions. + * Stops all reactive subscriptions and the periodic refresh timer. */ stop(): void { this.started = false + this.cancelScheduledRefresh() for (const cleanup of this.cleanups) { cleanup() } @@ -279,11 +316,14 @@ export class FeedEngine { items.sort((a, b) => b.priority - a.priority) - this.notifySubscribers({ + const result: FeedResult = { context: this.context, items: items as TItems[], errors, - }) + } + this.updateCache(result) + + this.notifySubscribers(result) } private collectDependents(sourceId: string, graph: SourceGraph): string[] { @@ -307,11 +347,46 @@ export class FeedEngine { return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id) } + private updateCache(result: FeedResult): 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 { // Simple immediate refresh for now - could add debouncing later - this.refresh().then((result) => { - this.notifySubscribers(result) - }) + this.refresh() + .then((result) => { + this.notifySubscribers(result) + }) + .catch(() => { + // Reactive refresh errors are non-fatal + }) } private notifySubscribers(result: FeedResult): void { diff --git a/packages/aris-core/src/index.ts b/packages/aris-core/src/index.ts index 0c5f5b8..349c51f 100644 --- a/packages/aris-core/src/index.ts +++ b/packages/aris-core/src/index.ts @@ -13,7 +13,7 @@ export type { FeedItem } from "./feed" export type { FeedSource } from "./feed-source" // Feed Engine -export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine" +export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine" export { FeedEngine } from "./feed-engine" // =============================================================================