Compare commits

...

3 Commits

Author SHA1 Message Date
f3db2e35e7 refactor: use reduce for stale group filtering
Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 15:56:28 +00:00
46596e53d0 fix: filter stale item IDs from groups after pipeline
Groups accumulated during the pipeline can reference items
that a later processor suppressed. The engine now strips
stale IDs and drops empty groups before returning.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 15:54:35 +00:00
bf309fce20 docs: document intentional TItems cast in post-processor merge
Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 15:51:18 +00:00
2 changed files with 41 additions and 1 deletions

View File

@@ -306,6 +306,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
const enhancement = await processor(currentItems)
if (enhancement.additionalItems?.length) {
// Post-processors operate on FeedItem[] without knowledge of TItems.
// Additional items are merged untyped — this is intentional. The
// processor contract is "FeedItem in, FeedItem out"; type narrowing
// is the caller's responsibility when consuming FeedResult.
currentItems = [...currentItems, ...(enhancement.additionalItems as TItems[])]
}
@@ -327,7 +331,17 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
}
return { items: currentItems, groupedItems: allGroupedItems, errors: allErrors }
// Remove stale item IDs from groups and drop empty groups
const itemIds = new Set(currentItems.map((item) => item.id))
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
const ids = group.itemIds.filter((id) => itemIds.has(id))
if (ids.length > 0) {
acc.push({ ...group, itemIds: ids })
}
return acc
}, [])
return { items: currentItems, groupedItems: validGroups, errors: allErrors }
}
private ensureGraph(): SourceGraph {

View File

@@ -173,6 +173,32 @@ describe("FeedPostProcessor", () => {
])
})
test("stale item IDs are removed from groups after suppression", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Afternoon" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([{ itemIds: ["c2"], summary: "Afternoon" }])
})
test("groups with all items suppressed are dropped", async () => {
const engine = new FeedEngine()
.register(createCalendarSource([calendarItem("c1", "Meeting A")]))
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1"], summary: "Solo" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toBeUndefined()
})
test("groupedItems is omitted when no processors produce groups", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))