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>
This commit is contained in:
2026-01-18 23:32:47 +00:00
parent 80192c6dc1
commit 1d9de2851a
4 changed files with 326 additions and 146 deletions

View File

@@ -6,43 +6,61 @@ Core orchestration layer for ARIS feed reconciliation.
```mermaid ```mermaid
flowchart TB flowchart TB
subgraph Providers["Context Providers"] subgraph Sources["Feed Sources (Graph)"]
LP[Location Provider] LS[Location Source]
MP[Music Provider] WS[Weather Source]
TS[TFL Source]
CS[Calendar Source]
end end
subgraph Bridge["ContextBridge"] LS --> WS
direction TB LS --> TS
B1[Manages providers]
B2[Forwards updates]
B3[Gathers on refresh]
end
subgraph Controller["FeedController"] subgraph Controller["FeedController"]
direction TB direction TB
C1[Holds context] C1[Holds context]
C2[Debounces updates] C2[Manages source graph]
C3[Reconciles sources] C3[Reconciles on update]
C4[Notifies subscribers] C4[Notifies subscribers]
end end
subgraph Sources["Data Sources"] Sources --> Controller
WS[Weather] Controller --> Sub[Subscribers]
TS[TFL]
CS[Calendar]
end
LP & MP --> Bridge
Bridge -->|pushContextUpdate| Controller
Controller -->|query| Sources
Controller -->|subscribe| 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 ```ts
import { contextKey, type ContextKey } from "@aris/core" import { contextKey, type ContextKey } from "@aris/core"
@@ -50,141 +68,97 @@ import { contextKey, type ContextKey } from "@aris/core"
interface Location { interface Location {
lat: number lat: number
lng: number lng: number
accuracy: number
} }
export const LocationKey: ContextKey<Location> = contextKey("location") 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 ```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> { onContextUpdate(callback, _getContext) {
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 {
const watchId = navigator.geolocation.watchPosition((pos) => { const watchId = navigator.geolocation.watchPosition((pos) => {
callback({ callback({
lat: pos.coords.latitude, [LocationKey]: { lat: pos.coords.latitude, lng: pos.coords.longitude },
lng: pos.coords.longitude,
accuracy: pos.coords.accuracy,
}) })
}) })
return () => navigator.geolocation.clearWatch(watchId) return () => navigator.geolocation.clearWatch(watchId)
} },
async fetchCurrentValue(): Promise<Location> { async fetchContext() {
const pos = await new Promise<GeolocationPosition>((resolve, reject) => { const pos = await getCurrentPosition()
navigator.geolocation.getCurrentPosition(resolve, reject)
})
return { return {
lat: pos.coords.latitude, [LocationKey]: { lat: pos.coords.latitude, lng: pos.coords.longitude },
lng: pos.coords.longitude,
accuracy: pos.coords.accuracy,
} }
} },
} }
``` ```
### Wire It Together ### Define a Source with Dependencies
```ts ```ts
import { ContextBridge, FeedController } from "@aris/core" import type { FeedSource, FeedItem } from "@aris/core"
import { contextValue } from "@aris/core"
// Create controller with data sources type WeatherItem = FeedItem<"weather", { temp: number; condition: string }>
const controller = new FeedController({ debounceMs: 100 })
.addDataSource(weatherSource)
.addDataSource(tflSource)
// Bridge context providers to controller const weatherSource: FeedSource<WeatherItem> = {
const bridge = new ContextBridge(controller) id: "weather",
.addProvider(locationProvider) dependencies: ["location"],
.addProvider(musicProvider)
// Subscribe to feed updates async fetchContext(context) {
controller.subscribe((result) => { const location = contextValue(context, LocationKey)
console.log("Feed items:", result.items) if (!location) return {}
console.log("Errors:", result.errors)
})
// Manual refresh (gathers from all providers) const weather = await fetchWeatherApi(location)
await bridge.refresh() return { [WeatherKey]: weather }
},
// Direct context update (bypasses providers) async fetchItems(context) {
controller.pushContextUpdate({ const weather = contextValue(context, WeatherKey)
[CurrentTrackKey]: { trackId: "123", title: "Song", artist: "Artist", startedAt: new Date() }, if (!weather) return []
})
// Cleanup return [
bridge.stop() {
controller.stop() id: `weather-${Date.now()}`,
``` type: "weather",
priority: 0.5,
### Per-User Pattern timestamp: new Date(),
data: { temp: weather.temp, condition: weather.condition },
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)
}
} }
``` ```
### 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 ## API
### Context ### Context
@@ -196,24 +170,17 @@ function onUserDisconnect(userId: string) {
| `contextValue(context, key)` | Type-safe context value accessor | | `contextValue(context, key)` | Type-safe context value accessor |
| `Context` | Time + arbitrary key-value bag | | `Context` | Time + arbitrary key-value bag |
### Data Sources ### Feed
| Export | Description | | Export | Description |
| ---------------------------- | --------------------------------- | | ------------------------ | ------------------------ |
| `DataSource<TItem, TConfig>` | Interface for feed item producers | | `FeedSource<TItem>` | Unified source interface |
| `FeedItem<TType, TData>` | Single item in the feed | | `FeedItem<TType, TData>` | Single item in the feed |
### Orchestration ### Legacy (deprecated)
| Export | Description | | Export | Description |
| -------------------- | ---------------------------------------------------- | | ---------------------------- | ------------------------ |
| `FeedController` | Holds context, debounces updates, reconciles sources | | `DataSource<TItem, TConfig>` | Use `FeedSource` instead |
| `ContextProvider<T>` | Reactive + on-demand context value provider | | `ContextProvider<T>` | Use `FeedSource` instead |
| `ContextBridge` | Bridges providers to controller | | `ContextBridge` | Use source graph instead |
### Reconciler
| Export | Description |
| -------------------- | --------------------------------------------- |
| `Reconciler` | Low-level: queries sources, sorts by priority |
| `ReconcileResult<T>` | Items + errors from reconciliation |

View File

@@ -0,0 +1,134 @@
/**
* Example wiring of FeedSource graph.
* NOT for documentation - just to visualize the interface.
*/
import type { Context, ContextKey, FeedItem, FeedSource } from "./index"
import { contextKey, contextValue } from "./index"
// ============================================================================
// Context Keys - exported by each package
// ============================================================================
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
// ============================================================================
interface WeatherFeedItem extends FeedItem<"weather", { temperature: number; condition: string }> {}
// ============================================================================
// Sources
// ============================================================================
// Location source - context only, no feed items
const locationSource: FeedSource = {
id: "location",
onContextUpdate(callback, _getContext) {
// Reactive: browser pushes location changes
const watchId = navigator.geolocation.watchPosition((pos) => {
callback({
[LocationKey]: {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
},
})
})
return () => navigator.geolocation.clearWatch(watchId)
},
async fetchContext(_context) {
// On-demand: manual refresh
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject)
})
return {
[LocationKey]: {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
},
}
},
}
// Weather source - depends on location, provides context + feed items
const weatherSource: FeedSource<WeatherFeedItem> = {
id: "weather",
dependencies: ["location"],
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
// Fetch weather from API
const weather = await fetchWeatherFromApi(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,
},
},
]
},
}
// ============================================================================
// Graph wiring (conceptual - FeedSourceGraph not yet implemented)
// ============================================================================
// const graph = new FeedSourceGraph([
// locationSource,
// weatherSource,
// ])
//
// // Graph validates:
// // - All dependencies exist
// // - No circular dependencies
// // - Topologically sorts sources
//
// // On refresh:
// // 1. fetchContext on location (no deps)
// // 2. fetchContext on weather (has location in context now)
// // 3. fetchItems on all sources
// // 4. Return combined feed items
//
// // On reactive update from location:
// // 1. Update context with new location
// // 2. Trigger weather.fetchContext (it depends on location)
// // 3. Trigger weather.fetchItems
// // 4. Notify subscribers
// ============================================================================
// Helpers (mock)
// ============================================================================
async function fetchWeatherFromApi(_location: Location): Promise<Weather> {
return { temperature: 20, condition: "sunny" }
}
export { locationSource, weatherSource }

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 // Feed
export type { FeedItem } from "./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" export type { DataSource } from "./data-source"
// Context Provider // Context Provider