From 1d9de2851a1cef1a45277dab927739e9d1c6fffa Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 18 Jan 2026 23:32:47 +0000 Subject: [PATCH 1/3] 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 From 286a933d1e9c163dc058e003407f92ff74a1002c Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 18 Jan 2026 23:45:05 +0000 Subject: [PATCH 2/3] 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 --- .../aris-core/src/feed-source.example.txt | 134 ------ packages/aris-core/src/feed-source.test.ts | 422 ++++++++++++++++++ 2 files changed, 422 insertions(+), 134 deletions(-) delete mode 100644 packages/aris-core/src/feed-source.example.txt create mode 100644 packages/aris-core/src/feed-source.test.ts diff --git a/packages/aris-core/src/feed-source.example.txt b/packages/aris-core/src/feed-source.example.txt deleted file mode 100644 index a291d64..0000000 --- a/packages/aris-core/src/feed-source.example.txt +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 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.test.ts b/packages/aris-core/src/feed-source.test.ts new file mode 100644 index 0000000..3f81e0f --- /dev/null +++ b/packages/aris-core/src/feed-source.test.ts @@ -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 = contextKey("location") +const WeatherKey: ContextKey = 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) => 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 = async () => ({ + temperature: 20, + condition: "sunny", + }), +): FeedSource { + 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 { + 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 + sorted: FeedSource[] + dependents: Map +} + +function buildGraph(sources: FeedSource[]): SourceGraph { + const byId = new Map() + 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() + const visiting = new Set() + 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() + 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 + }) + }) +}) From 9a47dda767a6ac9f387bb1abf63e7b16aa5d5a87 Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 18 Jan 2026 23:46:38 +0000 Subject: [PATCH 3/3] test(core): remove legacy integration tests Tests were for DataSource/ContextProvider/ContextBridge which are now deprecated in favor of FeedSource. Co-authored-by: Ona --- packages/aris-core/src/integration.test.ts | 336 --------------------- 1 file changed, 336 deletions(-) delete mode 100644 packages/aris-core/src/integration.test.ts diff --git a/packages/aris-core/src/integration.test.ts b/packages/aris-core/src/integration.test.ts deleted file mode 100644 index ea02e7f..0000000 --- a/packages/aris-core/src/integration.test.ts +++ /dev/null @@ -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 = contextKey("location") -const CurrentTrackKey: ContextKey = contextKey("currentTrack") - -// ============================================================================= -// DATA SOURCES -// ============================================================================= - -type WeatherItem = FeedItem<"weather", { temp: number; condition: string }> - -function createWeatherSource(): DataSource { - 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 { - 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 { - 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 { - 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 { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -type AppFeedItem = WeatherItem | TflItem | MusicContextItem - -// ============================================================================= -// TESTS -// ============================================================================= - -describe("Integration", () => { - let controller: FeedController - let bridge: ContextBridge - let locationProvider: SimulatedLocationProvider - - afterEach(() => { - bridge?.stop() - controller?.stop() - }) - - test("location update triggers feed with location-dependent sources", async () => { - controller = new FeedController({ 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({ 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({ 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({ 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({ 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 = { - 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({ 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) - }) -})