mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
2 Commits
2fc20759dd
...
3d492a5d56
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d492a5d56 | |||
|
08dd437952
|
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user