mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 13:11:17 +00:00
Compare commits
6 Commits
b73e603c90
...
feat/feed-
| Author | SHA1 | Date | |
|---|---|---|---|
|
9a47dda767
|
|||
|
286a933d1e
|
|||
|
1d9de2851a
|
|||
| 80192c6dc1 | |||
|
0eb77b73c6
|
|||
| dfce846c9a |
@@ -39,3 +39,4 @@ Use Bun exclusively. Do not use npm or yarn.
|
|||||||
|
|
||||||
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
|
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
|
||||||
- Commits: conventional commit format, title <= 50 chars
|
- Commits: conventional commit format, title <= 50 chars
|
||||||
|
- Signing: If `GPG_PRIVATE_KEY_PASSPHRASE` env var is available, use it to sign commits with `git commit -S`
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
|
|||||||
422
packages/aris-core/src/feed-source.test.ts
Normal file
422
packages/aris-core/src/feed-source.test.ts
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
76
packages/aris-core/src/feed-source.ts
Normal file
76
packages/aris-core/src/feed-source.ts
Normal 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[]>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user