refactor: make fetchContext required on FeedSource

Sources that cannot provide context now return null
instead of omitting the method. The engine checks the
return value rather than method existence.

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-02-14 16:20:24 +00:00
parent 476c6f06d9
commit 1f2920a7ad
12 changed files with 154 additions and 50 deletions

View File

@@ -74,7 +74,7 @@ function createWeatherSource(
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
if (!location) return null
const weather = await fetchWeather(location)
return { [WeatherKey]: weather }
@@ -105,6 +105,10 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
id: "alert",
dependencies: ["weather"],
async fetchContext() {
return null
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
@@ -169,6 +173,9 @@ describe("FeedEngine", () => {
const orphan: FeedSource = {
id: "orphan",
dependencies: ["nonexistent"],
async fetchContext() {
return null
},
}
engine.register(orphan)
@@ -180,8 +187,20 @@ describe("FeedEngine", () => {
test("throws on circular dependency", () => {
const engine = new FeedEngine()
const a: FeedSource = { id: "a", dependencies: ["b"] }
const b: FeedSource = { id: "b", dependencies: ["a"] }
const a: FeedSource = {
id: "a",
dependencies: ["b"],
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
async fetchContext() {
return null
},
}
engine.register(a).register(b)
@@ -190,9 +209,27 @@ describe("FeedEngine", () => {
test("throws on longer cycles", () => {
const engine = new FeedEngine()
const a: FeedSource = { id: "a", dependencies: ["c"] }
const b: FeedSource = { id: "b", dependencies: ["a"] }
const c: FeedSource = { id: "c", dependencies: ["b"] }
const a: FeedSource = {
id: "a",
dependencies: ["c"],
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
async fetchContext() {
return null
},
}
const c: FeedSource = {
id: "c",
dependencies: ["b"],
async fetchContext() {
return null
},
}
engine.register(a).register(b).register(c)
@@ -282,7 +319,7 @@ describe("FeedEngine", () => {
const location: FeedSource = {
id: "location",
async fetchContext() {
return {} // No location available
return null // No location available
},
}
@@ -316,6 +353,9 @@ describe("FeedEngine", () => {
test("captures errors from fetchItems", async () => {
const failing: FeedSource = {
id: "failing",
async fetchContext() {
return null
},
async fetchItems() {
throw new Error("Items fetch failed")
},
@@ -340,6 +380,9 @@ describe("FeedEngine", () => {
const working: FeedSource = {
id: "working",
async fetchContext() {
return null
},
async fetchItems() {
return [
{

View File

@@ -89,16 +89,16 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
// Run fetchContext in topological order
for (const source of graph.sorted) {
if (source.fetchContext) {
try {
const update = await source.fetchContext(context)
try {
const update = await source.fetchContext(context)
if (update) {
context = { ...context, ...update }
} catch (err) {
errors.push({
sourceId: source.id,
error: err instanceof Error ? err : new Error(String(err)),
})
}
} catch (err) {
errors.push({
sourceId: source.id,
error: err instanceof Error ? err : new Error(String(err)),
})
}
}
@@ -208,10 +208,12 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
// Re-run fetchContext for dependents in order
for (const id of toRefresh) {
const source = graph.sources.get(id)
if (source?.fetchContext) {
if (source) {
try {
const update = await source.fetchContext(this.context)
this.context = { ...this.context, ...update }
if (update) {
this.context = { ...this.context, ...update }
}
} catch {
// Errors during reactive updates are logged but don't stop propagation
}

View File

@@ -73,7 +73,7 @@ function createWeatherSource(
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
if (!location) return null
const weather = await fetchWeather(location)
return { [WeatherKey]: weather }
@@ -104,6 +104,10 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
id: "alert",
dependencies: ["weather"],
async fetchContext() {
return null
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
@@ -194,8 +198,8 @@ async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; ite
// Run fetchContext in topological order
for (const source of graph.sorted) {
if (source.fetchContext) {
const update = await source.fetchContext(context)
const update = await source.fetchContext(context)
if (update) {
context = { ...context, ...update }
}
}
@@ -245,9 +249,15 @@ describe("FeedSource", () => {
expect(source.id).toBe("alert")
expect(source.dependencies).toEqual(["weather"])
expect(source.fetchContext).toBeUndefined()
expect(source.fetchContext).toBeDefined()
expect(source.fetchItems).toBeDefined()
})
test("source without context returns null from fetchContext", async () => {
const source = createAlertSource()
const result = await source.fetchContext({ time: new Date() })
expect(result).toBeNull()
})
})
describe("graph validation", () => {
@@ -255,6 +265,9 @@ describe("FeedSource", () => {
const orphan: FeedSource = {
id: "orphan",
dependencies: ["nonexistent"],
async fetchContext() {
return null
},
}
expect(() => buildGraph([orphan])).toThrow(
@@ -263,16 +276,46 @@ describe("FeedSource", () => {
})
test("detects circular dependencies", () => {
const a: FeedSource = { id: "a", dependencies: ["b"] }
const b: FeedSource = { id: "b", dependencies: ["a"] }
const a: FeedSource = {
id: "a",
dependencies: ["b"],
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
async fetchContext() {
return null
},
}
expect(() => buildGraph([a, b])).toThrow("Circular dependency detected: a → b → a")
})
test("detects longer cycles", () => {
const a: FeedSource = { id: "a", dependencies: ["c"] }
const b: FeedSource = { id: "b", dependencies: ["a"] }
const c: FeedSource = { id: "c", dependencies: ["b"] }
const a: FeedSource = {
id: "a",
dependencies: ["c"],
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
async fetchContext() {
return null
},
}
const c: FeedSource = {
id: "c",
dependencies: ["b"],
async fetchContext() {
return null
},
}
expect(() => buildGraph([a, b, c])).toThrow("Circular dependency detected")
})
@@ -376,12 +419,12 @@ describe("FeedSource", () => {
})
test("source without location context returns empty items", async () => {
// Location source exists but hasn't been updated (returns default 0,0)
// Location source exists but hasn't been updated
const location: FeedSource = {
id: "location",
async fetchContext() {
// Simulate no location available
return {}
return null
},
}

View File

@@ -36,6 +36,13 @@ import type { FeedItem } from "./feed"
* return createWeatherFeedItems(ctx.weather)
* },
* }
*
* // TFL source - no context to provide
* const tflSource: FeedSource<TflFeedItem> = {
* id: "tfl",
* fetchContext: async () => null,
* fetchItems: async (ctx) => { ... },
* }
* ```
*/
export interface FeedSource<TItem extends FeedItem = FeedItem> {
@@ -58,8 +65,9 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
/**
* Fetch context on-demand.
* Called during manual refresh or initial load.
* Return null if this source cannot provide context.
*/
fetchContext?(context: Context): Promise<Partial<Context>>
fetchContext(context: Context): Promise<Partial<Context> | null>
/**
* Subscribe to reactive feed item updates.