6 Commits

Author SHA1 Message Date
5df3dbd1b5 fix(core): correct main and types paths in package.json
Paths pointed to index.ts but actual file is at src/index.ts.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-19 00:08:04 +00:00
b7c7bcfc7c Merge pull request #9 from kennethnym/feat/feed-source-interface
feat(core): add FeedSource interface
2026-01-18 23:49:31 +00:00
9a47dda767 test(core): remove legacy integration tests
Tests were for DataSource/ContextProvider/ContextBridge which are now
deprecated in favor of FeedSource.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 23:46:38 +00:00
286a933d1e test(core): add FeedSource integration tests
Tests graph validation (dependency existence, cycle detection, topological
sort) and refresh behavior (context accumulation, item collection).

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 23:45:05 +00:00
1d9de2851a feat(core): add FeedSource interface
Unifies DataSource and ContextProvider into a single interface that
forms a dependency graph. Sources declare dependencies on other sources
and can provide context, feed items, or both.

Deprecates DataSource, ContextProvider, and ContextBridge.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 23:32:47 +00:00
80192c6dc1 Merge pull request #8 from kennethnym/docs/gpg-signing-instruction
docs: add GPG signing instruction to AGENTS.md
2026-01-18 20:41:06 +00:00
6 changed files with 616 additions and 484 deletions

View File

@@ -6,43 +6,61 @@ Core orchestration layer for ARIS feed reconciliation.
```mermaid
flowchart TB
subgraph Providers["Context Providers"]
LP[Location Provider]
MP[Music Provider]
subgraph Sources["Feed Sources (Graph)"]
LS[Location Source]
WS[Weather Source]
TS[TFL Source]
CS[Calendar Source]
end
subgraph Bridge["ContextBridge"]
direction TB
B1[Manages providers]
B2[Forwards updates]
B3[Gathers on refresh]
end
LS --> WS
LS --> TS
subgraph Controller["FeedController"]
direction TB
C1[Holds context]
C2[Debounces updates]
C3[Reconciles sources]
C2[Manages source graph]
C3[Reconciles on update]
C4[Notifies subscribers]
end
subgraph Sources["Data Sources"]
WS[Weather]
TS[TFL]
CS[Calendar]
end
LP & MP --> Bridge
Bridge -->|pushContextUpdate| Controller
Controller -->|query| Sources
Controller -->|subscribe| Sub[Subscribers]
Sources --> Controller
Controller --> Sub[Subscribers]
```
## Usage
## Concepts
### Define Context Keys
### FeedSource
Each package defines its own typed context keys:
A unified interface for sources that provide context and/or feed items. Sources form a dependency graph.
```ts
interface FeedSource<TItem extends FeedItem = FeedItem> {
readonly id: string
readonly dependencies?: readonly string[]
// Context production (optional)
onContextUpdate?(
callback: (update: Partial<Context>) => void,
getContext: () => Context,
): () => void
fetchContext?(context: Context): Promise<Partial<Context>>
// Feed item production (optional)
onItemsUpdate?(callback: (items: TItem[]) => void, getContext: () => Context): () => void
fetchItems?(context: Context): Promise<TItem[]>
}
```
A source may:
- Provide context for other sources (implement `fetchContext`/`onContextUpdate`)
- Produce feed items (implement `fetchItems`/`onItemsUpdate`)
- Both
### Context Keys
Each package exports typed context keys for type-safe access:
```ts
import { contextKey, type ContextKey } from "@aris/core"
@@ -50,141 +68,97 @@ import { contextKey, type ContextKey } from "@aris/core"
interface Location {
lat: number
lng: number
accuracy: number
}
export const LocationKey: ContextKey<Location> = contextKey("location")
```
### Create Data Sources
## Usage
Data sources query external APIs and return feed items:
### Define a Context-Only Source
```ts
import { contextValue, type Context, type DataSource, type FeedItem } from "@aris/core"
import type { FeedSource } from "@aris/core"
type WeatherItem = FeedItem<"weather", { temp: number; condition: string }>
const locationSource: FeedSource = {
id: "location",
class WeatherDataSource implements DataSource<WeatherItem> {
readonly type = "weather"
async query(context: Context): Promise<WeatherItem[]> {
const location = contextValue(context, LocationKey)
if (!location) return []
const data = await fetchWeather(location.lat, location.lng)
return [
{
id: `weather-${Date.now()}`,
type: this.type,
priority: 0.5,
timestamp: context.time,
data: { temp: data.temp, condition: data.condition },
},
]
}
}
```
### Create Context Providers
Context providers push updates reactively and provide current values on demand:
```ts
import type { ContextProvider } from "@aris/core"
class LocationProvider implements ContextProvider<Location> {
readonly key = LocationKey
onUpdate(callback: (value: Location) => void): () => void {
onContextUpdate(callback, _getContext) {
const watchId = navigator.geolocation.watchPosition((pos) => {
callback({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracy: pos.coords.accuracy,
[LocationKey]: { lat: pos.coords.latitude, lng: pos.coords.longitude },
})
})
return () => navigator.geolocation.clearWatch(watchId)
}
},
async fetchCurrentValue(): Promise<Location> {
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject)
})
async fetchContext() {
const pos = await getCurrentPosition()
return {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracy: pos.coords.accuracy,
[LocationKey]: { lat: pos.coords.latitude, lng: pos.coords.longitude },
}
}
},
}
```
### Wire It Together
### Define a Source with Dependencies
```ts
import { ContextBridge, FeedController } from "@aris/core"
import type { FeedSource, FeedItem } from "@aris/core"
import { contextValue } from "@aris/core"
// Create controller with data sources
const controller = new FeedController({ debounceMs: 100 })
.addDataSource(weatherSource)
.addDataSource(tflSource)
type WeatherItem = FeedItem<"weather", { temp: number; condition: string }>
// Bridge context providers to controller
const bridge = new ContextBridge(controller)
.addProvider(locationProvider)
.addProvider(musicProvider)
const weatherSource: FeedSource<WeatherItem> = {
id: "weather",
dependencies: ["location"],
// Subscribe to feed updates
controller.subscribe((result) => {
console.log("Feed items:", result.items)
console.log("Errors:", result.errors)
})
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
// Manual refresh (gathers from all providers)
await bridge.refresh()
const weather = await fetchWeatherApi(location)
return { [WeatherKey]: weather }
},
// Direct context update (bypasses providers)
controller.pushContextUpdate({
[CurrentTrackKey]: { trackId: "123", title: "Song", artist: "Artist", startedAt: new Date() },
})
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
// Cleanup
bridge.stop()
controller.stop()
```
### Per-User Pattern
Each user gets their own controller instance:
```ts
const connections = new Map<string, { controller: FeedController; bridge: ContextBridge }>()
function onUserConnect(userId: string, ws: WebSocket) {
const controller = new FeedController({ debounceMs: 100 })
.addDataSource(weatherSource)
.addDataSource(tflSource)
const bridge = new ContextBridge(controller).addProvider(createLocationProvider())
controller.subscribe((result) => {
ws.send(JSON.stringify({ type: "feed-update", items: result.items }))
})
connections.set(userId, { controller, bridge })
}
function onUserDisconnect(userId: string) {
const conn = connections.get(userId)
if (conn) {
conn.bridge.stop()
conn.controller.stop()
connections.delete(userId)
}
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: weather.temp, condition: weather.condition },
},
]
},
}
```
### Graph Behavior
The source graph:
1. Validates all dependencies exist
2. Detects circular dependencies
3. Topologically sorts sources
On refresh:
1. `fetchContext` runs in dependency order
2. `fetchItems` runs on all sources
3. Combined items returned to subscribers
On reactive update:
1. Source pushes context update via `onContextUpdate` callback
2. Dependent sources re-run `fetchContext`
3. Affected sources re-run `fetchItems`
4. Subscribers notified
## API
### Context
@@ -196,24 +170,17 @@ function onUserDisconnect(userId: string) {
| `contextValue(context, key)` | Type-safe context value accessor |
| `Context` | Time + arbitrary key-value bag |
### Data Sources
### Feed
| Export | Description |
| ---------------------------- | --------------------------------- |
| `DataSource<TItem, TConfig>` | Interface for feed item producers |
| `FeedItem<TType, TData>` | Single item in the feed |
| Export | Description |
| ------------------------ | ------------------------ |
| `FeedSource<TItem>` | Unified source interface |
| `FeedItem<TType, TData>` | Single item in the feed |
### Orchestration
### Legacy (deprecated)
| Export | Description |
| -------------------- | ---------------------------------------------------- |
| `FeedController` | Holds context, debounces updates, reconciles sources |
| `ContextProvider<T>` | Reactive + on-demand context value provider |
| `ContextBridge` | Bridges providers to controller |
### Reconciler
| Export | Description |
| -------------------- | --------------------------------------------- |
| `Reconciler` | Low-level: queries sources, sorts by priority |
| `ReconcileResult<T>` | Items + errors from reconciliation |
| Export | Description |
| ---------------------------- | ------------------------ |
| `DataSource<TItem, TConfig>` | Use `FeedSource` instead |
| `ContextProvider<T>` | Use `FeedSource` instead |
| `ContextBridge` | Use source graph instead |

View File

@@ -2,8 +2,8 @@
"name": "@aris/core",
"version": "0.0.0",
"type": "module",
"main": "index.ts",
"types": "index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ."
}

View File

@@ -0,0 +1,422 @@
import { describe, expect, test } from "bun:test"
import type { Context, ContextKey, FeedItem, FeedSource } from "./index"
import { contextKey, contextValue } from "./index"
// =============================================================================
// CONTEXT KEYS
// =============================================================================
interface Location {
lat: number
lng: number
}
interface Weather {
temperature: number
condition: string
}
const LocationKey: ContextKey<Location> = contextKey("location")
const WeatherKey: ContextKey<Weather> = contextKey("weather")
// =============================================================================
// FEED ITEMS
// =============================================================================
type WeatherFeedItem = FeedItem<"weather", { temperature: number; condition: string }>
type AlertFeedItem = FeedItem<"alert", { message: string }>
// =============================================================================
// TEST HELPERS
// =============================================================================
interface SimulatedLocationSource extends FeedSource {
simulateUpdate(location: Location): void
}
function createLocationSource(): SimulatedLocationSource {
let callback: ((update: Partial<Context>) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 }
return {
id: "location",
onContextUpdate(cb) {
callback = cb
return () => {
callback = null
}
},
async fetchContext() {
return { [LocationKey]: currentLocation }
},
simulateUpdate(location: Location) {
currentLocation = location
callback?.({ [LocationKey]: location })
},
}
}
function createWeatherSource(
fetchWeather: (location: Location) => Promise<Weather> = async () => ({
temperature: 20,
condition: "sunny",
}),
): FeedSource<WeatherFeedItem> {
return {
id: "weather",
dependencies: ["location"],
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
const weather = await fetchWeather(location)
return { [WeatherKey]: weather }
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: {
temperature: weather.temperature,
condition: weather.condition,
},
},
]
},
}
}
function createAlertSource(): FeedSource<AlertFeedItem> {
return {
id: "alert",
dependencies: ["weather"],
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
if (weather.condition === "storm") {
return [
{
id: "alert-storm",
type: "alert",
priority: 1.0,
timestamp: new Date(),
data: { message: "Storm warning!" },
},
]
}
return []
},
}
}
// =============================================================================
// GRAPH SIMULATION (until FeedController is updated)
// =============================================================================
interface SourceGraph {
sources: Map<string, FeedSource>
sorted: FeedSource[]
dependents: Map<string, string[]>
}
function buildGraph(sources: FeedSource[]): SourceGraph {
const byId = new Map<string, FeedSource>()
for (const source of sources) {
byId.set(source.id, source)
}
// Validate dependencies exist
for (const source of sources) {
for (const dep of source.dependencies ?? []) {
if (!byId.has(dep)) {
throw new Error(`Source "${source.id}" depends on "${dep}" which is not registered`)
}
}
}
// Check for cycles and topologically sort
const visited = new Set<string>()
const visiting = new Set<string>()
const sorted: FeedSource[] = []
function visit(id: string, path: string[]): void {
if (visiting.has(id)) {
const cycle = [...path.slice(path.indexOf(id)), id].join(" → ")
throw new Error(`Circular dependency detected: ${cycle}`)
}
if (visited.has(id)) return
visiting.add(id)
const source = byId.get(id)!
for (const dep of source.dependencies ?? []) {
visit(dep, [...path, id])
}
visiting.delete(id)
visited.add(id)
sorted.push(source)
}
for (const source of sources) {
visit(source.id, [])
}
// Build reverse dependency map
const dependents = new Map<string, string[]>()
for (const source of sources) {
for (const dep of source.dependencies ?? []) {
const list = dependents.get(dep) ?? []
list.push(source.id)
dependents.set(dep, list)
}
}
return { sources: byId, sorted, dependents }
}
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
let context: Context = { time: new Date() }
// Run fetchContext in topological order
for (const source of graph.sorted) {
if (source.fetchContext) {
const update = await source.fetchContext(context)
context = { ...context, ...update }
}
}
// Run fetchItems on all sources
const items: FeedItem[] = []
for (const source of graph.sorted) {
if (source.fetchItems) {
const sourceItems = await source.fetchItems(context)
items.push(...sourceItems)
}
}
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
return { context, items }
}
// =============================================================================
// TESTS
// =============================================================================
describe("FeedSource", () => {
describe("interface", () => {
test("source with only context production", () => {
const source = createLocationSource()
expect(source.id).toBe("location")
expect(source.dependencies).toBeUndefined()
expect(source.fetchContext).toBeDefined()
expect(source.onContextUpdate).toBeDefined()
expect(source.fetchItems).toBeUndefined()
})
test("source with dependencies and both context and items", () => {
const source = createWeatherSource()
expect(source.id).toBe("weather")
expect(source.dependencies).toEqual(["location"])
expect(source.fetchContext).toBeDefined()
expect(source.fetchItems).toBeDefined()
})
test("source with only item production", () => {
const source = createAlertSource()
expect(source.id).toBe("alert")
expect(source.dependencies).toEqual(["weather"])
expect(source.fetchContext).toBeUndefined()
expect(source.fetchItems).toBeDefined()
})
})
describe("graph validation", () => {
test("validates all dependencies exist", () => {
const orphan: FeedSource = {
id: "orphan",
dependencies: ["nonexistent"],
}
expect(() => buildGraph([orphan])).toThrow(
'Source "orphan" depends on "nonexistent" which is not registered',
)
})
test("detects circular dependencies", () => {
const a: FeedSource = { id: "a", dependencies: ["b"] }
const b: FeedSource = { id: "b", dependencies: ["a"] }
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"] }
expect(() => buildGraph([a, b, c])).toThrow("Circular dependency detected")
})
test("topologically sorts sources", () => {
const location = createLocationSource()
const weather = createWeatherSource()
const alert = createAlertSource()
// Register in wrong order
const graph = buildGraph([alert, weather, location])
expect(graph.sorted.map((s) => s.id)).toEqual(["location", "weather", "alert"])
})
test("builds reverse dependency map", () => {
const location = createLocationSource()
const weather = createWeatherSource()
const alert = createAlertSource()
const graph = buildGraph([location, weather, alert])
expect(graph.dependents.get("location")).toEqual(["weather"])
expect(graph.dependents.get("weather")).toEqual(["alert"])
expect(graph.dependents.get("alert")).toBeUndefined()
})
})
describe("graph refresh", () => {
test("runs fetchContext in dependency order", async () => {
const order: string[] = []
const location: FeedSource = {
id: "location",
async fetchContext() {
order.push("location")
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
},
}
const weather: FeedSource = {
id: "weather",
dependencies: ["location"],
async fetchContext(ctx) {
order.push("weather")
const loc = contextValue(ctx, LocationKey)
expect(loc).toBeDefined()
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
}
const graph = buildGraph([weather, location])
await refreshGraph(graph)
expect(order).toEqual(["location", "weather"])
})
test("accumulates context across sources", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const graph = buildGraph([location, weather])
const { context } = await refreshGraph(graph)
expect(contextValue(context, LocationKey)).toEqual({ lat: 51.5, lng: -0.1 })
expect(contextValue(context, WeatherKey)).toEqual({ temperature: 20, condition: "sunny" })
})
test("collects items from all sources", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const graph = buildGraph([location, weather])
const { items } = await refreshGraph(graph)
expect(items).toHaveLength(1)
expect(items[0]!.type).toBe("weather")
})
test("downstream source receives upstream context", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource(async () => ({
temperature: 15,
condition: "storm",
}))
const alert = createAlertSource()
const graph = buildGraph([location, weather, alert])
const { items } = await refreshGraph(graph)
expect(items).toHaveLength(2)
expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[1]!.type).toBe("weather") // priority 0.5
})
test("source without location context returns empty items", async () => {
// Location source exists but hasn't been updated (returns default 0,0)
const location: FeedSource = {
id: "location",
async fetchContext() {
// Simulate no location available
return {}
},
}
const weather = createWeatherSource()
const graph = buildGraph([location, weather])
const { context, items } = await refreshGraph(graph)
expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0)
})
})
describe("reactive updates", () => {
test("onContextUpdate receives callback and returns cleanup", () => {
const location = createLocationSource()
let updateCount = 0
const cleanup = location.onContextUpdate!(
() => {
updateCount++
},
() => ({ time: new Date() }),
)
location.simulateUpdate({ lat: 1, lng: 1 })
expect(updateCount).toBe(1)
location.simulateUpdate({ lat: 2, lng: 2 })
expect(updateCount).toBe(2)
cleanup()
location.simulateUpdate({ lat: 3, lng: 3 })
expect(updateCount).toBe(2) // no more updates after cleanup
})
})
})

View File

@@ -0,0 +1,76 @@
import type { Context } from "./context"
import type { FeedItem } from "./feed"
/**
* Unified interface for sources that provide context and/or feed items.
*
* Sources form a dependency graph - a source declares which other sources
* it depends on, and the graph ensures dependencies are resolved before
* dependents run.
*
* A source may:
* - Provide context for other sources (implement fetchContext/onContextUpdate)
* - Produce feed items (implement fetchItems/onItemsUpdate)
* - Both
*
* @example
* ```ts
* // Location source - provides context only
* const locationSource: FeedSource = {
* id: "location",
* fetchContext: async () => {
* const pos = await getCurrentPosition()
* return { location: { lat: pos.coords.latitude, lng: pos.coords.longitude } }
* },
* }
*
* // Weather source - depends on location, provides both context and items
* const weatherSource: FeedSource<WeatherFeedItem> = {
* id: "weather",
* dependencies: ["location"],
* fetchContext: async (ctx) => {
* const weather = await fetchWeather(ctx.location)
* return { weather }
* },
* fetchItems: async (ctx) => {
* return createWeatherFeedItems(ctx.weather)
* },
* }
* ```
*/
export interface FeedSource<TItem extends FeedItem = FeedItem> {
/** Unique identifier for this source */
readonly id: string
/** IDs of sources this source depends on */
readonly dependencies?: readonly string[]
/**
* Subscribe to reactive context updates.
* Called when the source can push context changes proactively.
* Returns cleanup function.
*/
onContextUpdate?(
callback: (update: Partial<Context>) => void,
getContext: () => Context,
): () => void
/**
* Fetch context on-demand.
* Called during manual refresh or initial load.
*/
fetchContext?(context: Context): Promise<Partial<Context>>
/**
* Subscribe to reactive feed item updates.
* Called when the source can push item changes proactively.
* Returns cleanup function.
*/
onItemsUpdate?(callback: (items: TItem[]) => void, getContext: () => Context): () => void
/**
* Fetch feed items on-demand.
* Called during manual refresh or when dependencies update.
*/
fetchItems?(context: Context): Promise<TItem[]>
}

View File

@@ -5,7 +5,10 @@ export { contextKey, contextValue } from "./context"
// Feed
export type { FeedItem } from "./feed"
// Data Source
// Feed Source
export type { FeedSource } from "./feed-source"
// Data Source (deprecated - use FeedSource)
export type { DataSource } from "./data-source"
// Context Provider

View File

@@ -1,336 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { ContextKey, ContextProvider, DataSource, FeedItem } from "./index"
import { contextKey, contextValue, ContextBridge, FeedController } from "./index"
// =============================================================================
// CONTEXT KEYS
// =============================================================================
interface Location {
lat: number
lng: number
accuracy: number
}
interface CurrentTrack {
trackId: string
title: string
artist: string
startedAt: Date
}
const LocationKey: ContextKey<Location> = contextKey("location")
const CurrentTrackKey: ContextKey<CurrentTrack> = contextKey("currentTrack")
// =============================================================================
// DATA SOURCES
// =============================================================================
type WeatherItem = FeedItem<"weather", { temp: number; condition: string }>
function createWeatherSource(): DataSource<WeatherItem> {
return {
type: "weather",
async query(context) {
const location = contextValue(context, LocationKey)
if (!location) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: context.time,
data: { temp: 18, condition: "cloudy" },
},
]
},
}
}
type TflItem = FeedItem<"tfl-alert", { line: string; status: string }>
function createTflSource(): DataSource<TflItem> {
return {
type: "tfl-alert",
async query(context) {
const location = contextValue(context, LocationKey)
if (!location) return []
return [
{
id: "tfl-victoria-delays",
type: "tfl-alert",
priority: 0.8,
timestamp: context.time,
data: { line: "Victoria", status: "Minor delays" },
},
]
},
}
}
type MusicContextItem = FeedItem<"music-context", { suggestion: string }>
function createMusicContextSource(): DataSource<MusicContextItem> {
return {
type: "music-context",
async query(context) {
const track = contextValue(context, CurrentTrackKey)
if (!track) return []
return [
{
id: `music-ctx-${track.trackId}`,
type: "music-context",
priority: 0.3,
timestamp: context.time,
data: { suggestion: `You might also like similar artists to ${track.artist}` },
},
]
},
}
}
// =============================================================================
// CONTEXT PROVIDERS
// =============================================================================
interface SimulatedLocationProvider extends ContextProvider<Location> {
simulateUpdate(location: Location): void
}
function createLocationProvider(): SimulatedLocationProvider {
let callback: ((value: Location) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0, accuracy: 0 }
return {
key: LocationKey,
onUpdate(cb) {
callback = cb
return () => {
callback = null
}
},
async fetchCurrentValue() {
return currentLocation
},
simulateUpdate(location: Location) {
currentLocation = location
callback?.(location)
},
}
}
// =============================================================================
// HELPERS
// =============================================================================
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
type AppFeedItem = WeatherItem | TflItem | MusicContextItem
// =============================================================================
// TESTS
// =============================================================================
describe("Integration", () => {
let controller: FeedController<AppFeedItem>
let bridge: ContextBridge
let locationProvider: SimulatedLocationProvider
afterEach(() => {
bridge?.stop()
controller?.stop()
})
test("location update triggers feed with location-dependent sources", async () => {
controller = new FeedController<AppFeedItem>({ debounceMs: 10 })
.addDataSource(createWeatherSource())
.addDataSource(createTflSource())
.addDataSource(createMusicContextSource())
locationProvider = createLocationProvider()
bridge = new ContextBridge(controller).addProvider(locationProvider)
const results: Array<{ items: AppFeedItem[] }> = []
controller.subscribe((result) => {
results.push({ items: [...result.items] })
})
locationProvider.simulateUpdate({ lat: 51.5074, lng: -0.1278, accuracy: 10 })
await delay(50)
expect(results).toHaveLength(1)
expect(results[0]!.items).toHaveLength(2) // weather + tfl, no music
expect(results[0]!.items.map((i) => i.type).sort()).toEqual(["tfl-alert", "weather"])
})
test("music change triggers feed with music-dependent source", async () => {
controller = new FeedController<AppFeedItem>({ debounceMs: 10 })
.addDataSource(createWeatherSource())
.addDataSource(createTflSource())
.addDataSource(createMusicContextSource())
locationProvider = createLocationProvider()
bridge = new ContextBridge(controller).addProvider(locationProvider)
// Set initial location
locationProvider.simulateUpdate({ lat: 51.5074, lng: -0.1278, accuracy: 10 })
await delay(50)
const results: Array<{ items: AppFeedItem[] }> = []
controller.subscribe((result) => {
results.push({ items: [...result.items] })
})
// Push music change directly to controller
controller.pushContextUpdate({
[CurrentTrackKey]: {
trackId: "track-456",
title: "Bohemian Rhapsody",
artist: "Queen",
startedAt: new Date(),
},
})
await delay(50)
expect(results).toHaveLength(1)
expect(results[0]!.items).toHaveLength(3) // weather + tfl + music
expect(results[0]!.items.map((i) => i.type).sort()).toEqual([
"music-context",
"tfl-alert",
"weather",
])
const musicItem = results[0]!.items.find((i) => i.type === "music-context") as MusicContextItem
expect(musicItem.data.suggestion).toContain("Queen")
})
test("manual refresh gathers from all providers and reconciles", async () => {
controller = new FeedController<AppFeedItem>({ debounceMs: 10 })
.addDataSource(createWeatherSource())
.addDataSource(createTflSource())
locationProvider = createLocationProvider()
// Set location without triggering update
locationProvider.simulateUpdate({ lat: 40.7128, lng: -74.006, accuracy: 5 })
// Clear the callback so simulateUpdate doesn't trigger reconcile
const originalOnUpdate = locationProvider.onUpdate
locationProvider.onUpdate = (cb) => {
return originalOnUpdate(cb)
}
bridge = new ContextBridge(controller).addProvider(locationProvider)
const results: Array<{ items: AppFeedItem[] }> = []
controller.subscribe((result) => {
results.push({ items: [...result.items] })
})
// Manual refresh should gather current location and reconcile
await bridge.refresh()
await delay(50)
expect(results).toHaveLength(1)
expect(results[0]!.items).toHaveLength(2)
const ctx = controller.getContext()
expect(contextValue(ctx, LocationKey)).toEqual({ lat: 40.7128, lng: -74.006, accuracy: 5 })
})
test("context accumulates across multiple updates", async () => {
controller = new FeedController<AppFeedItem>({ debounceMs: 10 })
.addDataSource(createWeatherSource())
.addDataSource(createMusicContextSource())
locationProvider = createLocationProvider()
bridge = new ContextBridge(controller).addProvider(locationProvider)
// Location update
locationProvider.simulateUpdate({ lat: 51.5074, lng: -0.1278, accuracy: 10 })
await delay(50)
// Music update
controller.pushContextUpdate({
[CurrentTrackKey]: {
trackId: "track-789",
title: "Stairway to Heaven",
artist: "Led Zeppelin",
startedAt: new Date(),
},
})
await delay(50)
const ctx = controller.getContext()
expect(contextValue(ctx, LocationKey)).toEqual({ lat: 51.5074, lng: -0.1278, accuracy: 10 })
expect(contextValue(ctx, CurrentTrackKey)?.artist).toBe("Led Zeppelin")
})
test("items are sorted by priority descending", async () => {
controller = new FeedController<AppFeedItem>({ debounceMs: 10 })
.addDataSource(createWeatherSource()) // priority 0.5
.addDataSource(createTflSource()) // priority 0.8
.addDataSource(createMusicContextSource()) // priority 0.3
locationProvider = createLocationProvider()
bridge = new ContextBridge(controller).addProvider(locationProvider)
locationProvider.simulateUpdate({ lat: 51.5074, lng: -0.1278, accuracy: 10 })
controller.pushContextUpdate({
[CurrentTrackKey]: {
trackId: "track-1",
title: "Test",
artist: "Test",
startedAt: new Date(),
},
})
await delay(50)
const result = await controller.reconcile()
expect(result.items[0]!.type).toBe("tfl-alert") // 0.8
expect(result.items[1]!.type).toBe("weather") // 0.5
expect(result.items[2]!.type).toBe("music-context") // 0.3
})
test("cleanup stops providers and pending reconciles", async () => {
let queryCount = 0
const trackingSource: DataSource<WeatherItem> = {
type: "weather",
async query(context) {
queryCount++
const location = contextValue(context, LocationKey)
if (!location) return []
return [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: context.time,
data: { temp: 20, condition: "sunny" },
},
]
},
}
const ctrl = new FeedController<WeatherItem>({ debounceMs: 100 }).addDataSource(trackingSource)
locationProvider = createLocationProvider()
const br = new ContextBridge(ctrl).addProvider(locationProvider)
ctrl.subscribe(() => {})
// Trigger update but stop before debounce flushes
locationProvider.simulateUpdate({ lat: 51.5, lng: -0.1, accuracy: 10 })
br.stop()
ctrl.stop()
await delay(150)
expect(queryCount).toBe(0)
})
})