feat: runtime provider hotswap (#82)

Add ability to replace a FeedSourceProvider at runtime and propagate
the new source to all active (and pending) user sessions, invalidating
their feed caches.

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-19 23:32:29 +00:00
committed by GitHub
parent ca2664b617
commit 0095d9cd72
12 changed files with 717 additions and 81 deletions

View File

@@ -180,6 +180,31 @@ describe("FeedEngine", () => {
expect(engine.refresh()).resolves.toBeDefined()
})
test("register invalidates feed cache", async () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
engine.register(createWeatherSource())
expect(engine.lastFeed()).toBeNull()
})
test("unregister invalidates feed cache", async () => {
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine().register(location).register(weather)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
engine.unregister("weather")
expect(engine.lastFeed()).toBeNull()
})
})
describe("graph validation", () => {
@@ -934,4 +959,54 @@ describe("FeedEngine", () => {
engine.stop()
})
})
describe("invalidateCache", () => {
test("clears cached result", async () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
engine.invalidateCache()
expect(engine.lastFeed()).toBeNull()
})
test("is safe to call when no cache exists", () => {
const engine = new FeedEngine()
expect(() => engine.invalidateCache()).not.toThrow()
expect(engine.lastFeed()).toBeNull()
})
})
describe("isStarted", () => {
test("returns false before start", () => {
const engine = new FeedEngine()
expect(engine.isStarted()).toBe(false)
})
test("returns true after start", () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
engine.start()
expect(engine.isStarted()).toBe(true)
engine.stop()
})
test("returns false after stop", () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
engine.start()
engine.stop()
expect(engine.isStarted()).toBe(false)
})
})
})

View File

@@ -97,23 +97,33 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
/**
* Registers a FeedSource. Invalidates the cached graph.
* Registers a FeedSource. Invalidates the cached graph and feed cache.
*/
register<TItem extends FeedItem>(source: FeedSource<TItem>): FeedEngine<TItems | TItem> {
this.sources.set(source.id, source)
this.graph = null
this.invalidateCache()
return this as FeedEngine<TItems | TItem>
}
/**
* Unregisters a FeedSource by ID. Invalidates the cached graph.
* Unregisters a FeedSource by ID. Invalidates the cached graph and feed cache.
*/
unregister(sourceId: string): this {
this.sources.delete(sourceId)
this.graph = null
this.invalidateCache()
return this
}
/**
* Clears the cached feed result so the next access triggers a fresh refresh.
*/
invalidateCache(): void {
this.cachedResult = null
this.cachedAt = null
}
/**
* Registers a post-processor. Processors run in registration order
* after items are collected, on every update path.
@@ -249,6 +259,13 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.cleanups = []
}
/**
* Returns whether the engine is currently running reactive subscriptions.
*/
isStarted(): boolean {
return this.started
}
/**
* Returns the current accumulated context.
*/