From 1d9de2851a1cef1a45277dab927739e9d1c6fffa Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 18 Jan 2026 23:32:47 +0000 Subject: [PATCH] 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 --- packages/aris-core/README.md | 257 ++++++++---------- .../aris-core/src/feed-source.example.txt | 134 +++++++++ packages/aris-core/src/feed-source.ts | 76 ++++++ packages/aris-core/src/index.ts | 5 +- 4 files changed, 326 insertions(+), 146 deletions(-) create mode 100644 packages/aris-core/src/feed-source.example.txt create mode 100644 packages/aris-core/src/feed-source.ts diff --git a/packages/aris-core/README.md b/packages/aris-core/README.md index d933964..34df5f0 100644 --- a/packages/aris-core/README.md +++ b/packages/aris-core/README.md @@ -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 { + readonly id: string + readonly dependencies?: readonly string[] + + // Context production (optional) + onContextUpdate?( + callback: (update: Partial) => void, + getContext: () => Context, + ): () => void + fetchContext?(context: Context): Promise> + + // Feed item production (optional) + onItemsUpdate?(callback: (items: TItem[]) => void, getContext: () => Context): () => void + fetchItems?(context: Context): Promise +} +``` + +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 = 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 { - readonly type = "weather" - - async query(context: Context): Promise { - 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 { - 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 { - const pos = await new Promise((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 = { + 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() - -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` | Interface for feed item producers | -| `FeedItem` | Single item in the feed | +| Export | Description | +| ------------------------ | ------------------------ | +| `FeedSource` | Unified source interface | +| `FeedItem` | Single item in the feed | -### Orchestration +### Legacy (deprecated) -| Export | Description | -| -------------------- | ---------------------------------------------------- | -| `FeedController` | Holds context, debounces updates, reconciles sources | -| `ContextProvider` | Reactive + on-demand context value provider | -| `ContextBridge` | Bridges providers to controller | - -### Reconciler - -| Export | Description | -| -------------------- | --------------------------------------------- | -| `Reconciler` | Low-level: queries sources, sorts by priority | -| `ReconcileResult` | Items + errors from reconciliation | +| Export | Description | +| ---------------------------- | ------------------------ | +| `DataSource` | Use `FeedSource` instead | +| `ContextProvider` | Use `FeedSource` instead | +| `ContextBridge` | Use source graph instead | diff --git a/packages/aris-core/src/feed-source.example.txt b/packages/aris-core/src/feed-source.example.txt new file mode 100644 index 0000000..a291d64 --- /dev/null +++ b/packages/aris-core/src/feed-source.example.txt @@ -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 = contextKey("location") +const WeatherKey: ContextKey = 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((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 = { + 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 { + return { temperature: 20, condition: "sunny" } +} + +export { locationSource, weatherSource } diff --git a/packages/aris-core/src/feed-source.ts b/packages/aris-core/src/feed-source.ts new file mode 100644 index 0000000..aa9f364 --- /dev/null +++ b/packages/aris-core/src/feed-source.ts @@ -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 = { + * 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 { + /** 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) => void, + getContext: () => Context, + ): () => void + + /** + * Fetch context on-demand. + * Called during manual refresh or initial load. + */ + fetchContext?(context: Context): Promise> + + /** + * 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 +} diff --git a/packages/aris-core/src/index.ts b/packages/aris-core/src/index.ts index 6017d6a..0e0ad02 100644 --- a/packages/aris-core/src/index.ts +++ b/packages/aris-core/src/index.ts @@ -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