mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 05:01:17 +00:00
Compare commits
4 Commits
feat/sourc
...
feat/sourc
| Author | SHA1 | Date | |
|---|---|---|---|
| 66ee44b470 | |||
| 1893c516f3 | |||
| 181160b018 | |||
| 559f82ce96 |
@@ -8,14 +8,14 @@ bun install
|
||||
|
||||
## Packages
|
||||
|
||||
### @aris/data-source-tfl
|
||||
### @aris/source-tfl
|
||||
|
||||
TfL (Transport for London) data source for tube, overground, and Elizabeth line alerts.
|
||||
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
|
||||
|
||||
#### Testing
|
||||
|
||||
```bash
|
||||
cd packages/aris-data-source-tfl
|
||||
cd packages/aris-source-tfl
|
||||
bun run test
|
||||
```
|
||||
|
||||
|
||||
21
bun.lock
21
bun.lock
@@ -17,14 +17,6 @@
|
||||
"name": "@aris/core",
|
||||
"version": "0.0.0",
|
||||
},
|
||||
"packages/aris-data-source-tfl": {
|
||||
"name": "@aris/data-source-tfl",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"arktype": "^2.1.0",
|
||||
},
|
||||
},
|
||||
"packages/aris-data-source-weatherkit": {
|
||||
"name": "@aris/data-source-weatherkit",
|
||||
"version": "0.0.0",
|
||||
@@ -40,6 +32,15 @@
|
||||
"@aris/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/aris-source-tfl": {
|
||||
"name": "@aris/source-tfl",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"@aris/source-location": "workspace:*",
|
||||
"arktype": "^2.1.0",
|
||||
},
|
||||
},
|
||||
"packages/aris-source-weatherkit": {
|
||||
"name": "@aris/source-weatherkit",
|
||||
"version": "0.0.0",
|
||||
@@ -53,12 +54,12 @@
|
||||
"packages": {
|
||||
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
||||
|
||||
"@aris/data-source-tfl": ["@aris/data-source-tfl@workspace:packages/aris-data-source-tfl"],
|
||||
|
||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||
|
||||
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
|
||||
|
||||
"@aris/source-tfl": ["@aris/source-tfl@workspace:packages/aris-source-tfl"],
|
||||
|
||||
"@aris/source-weatherkit": ["@aris/source-weatherkit@workspace:packages/aris-source-weatherkit"],
|
||||
|
||||
"@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="],
|
||||
|
||||
458
packages/aris-core/src/feed-engine.test.ts
Normal file
458
packages/aris-core/src/feed-engine.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||
|
||||
import { FeedEngine } from "./feed-engine"
|
||||
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 []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TESTS
|
||||
// =============================================================================
|
||||
|
||||
describe("FeedEngine", () => {
|
||||
describe("registration", () => {
|
||||
test("registers sources", () => {
|
||||
const engine = new FeedEngine()
|
||||
const location = createLocationSource()
|
||||
|
||||
engine.register(location)
|
||||
|
||||
// Can refresh without error
|
||||
expect(engine.refresh()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("unregisters sources", async () => {
|
||||
const engine = new FeedEngine()
|
||||
const location = createLocationSource()
|
||||
|
||||
engine.register(location)
|
||||
engine.unregister("location")
|
||||
|
||||
const result = await engine.refresh()
|
||||
expect(result.items).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("allows chained registration", () => {
|
||||
const engine = new FeedEngine()
|
||||
.register(createLocationSource())
|
||||
.register(createWeatherSource())
|
||||
.register(createAlertSource())
|
||||
|
||||
expect(engine.refresh()).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("graph validation", () => {
|
||||
test("throws on missing dependency", () => {
|
||||
const engine = new FeedEngine()
|
||||
const orphan: FeedSource = {
|
||||
id: "orphan",
|
||||
dependencies: ["nonexistent"],
|
||||
}
|
||||
|
||||
engine.register(orphan)
|
||||
|
||||
expect(engine.refresh()).rejects.toThrow(
|
||||
'Source "orphan" depends on "nonexistent" which is not registered',
|
||||
)
|
||||
})
|
||||
|
||||
test("throws on circular dependency", () => {
|
||||
const engine = new FeedEngine()
|
||||
const a: FeedSource = { id: "a", dependencies: ["b"] }
|
||||
const b: FeedSource = { id: "b", dependencies: ["a"] }
|
||||
|
||||
engine.register(a).register(b)
|
||||
|
||||
expect(engine.refresh()).rejects.toThrow("Circular dependency detected: a → b → a")
|
||||
})
|
||||
|
||||
test("throws on longer cycles", () => {
|
||||
const engine = new FeedEngine()
|
||||
const a: FeedSource = { id: "a", dependencies: ["c"] }
|
||||
const b: FeedSource = { id: "b", dependencies: ["a"] }
|
||||
const c: FeedSource = { id: "c", dependencies: ["b"] }
|
||||
|
||||
engine.register(a).register(b).register(c)
|
||||
|
||||
expect(engine.refresh()).rejects.toThrow("Circular dependency detected")
|
||||
})
|
||||
})
|
||||
|
||||
describe("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 engine = new FeedEngine().register(weather).register(location)
|
||||
|
||||
await engine.refresh()
|
||||
|
||||
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 engine = new FeedEngine().register(location).register(weather)
|
||||
|
||||
const { context } = await engine.refresh()
|
||||
|
||||
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 engine = new FeedEngine().register(location).register(weather)
|
||||
|
||||
const { items } = await engine.refresh()
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]!.type).toBe("weather")
|
||||
})
|
||||
|
||||
test("sorts items by priority descending", async () => {
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
const weather = createWeatherSource(async () => ({
|
||||
temperature: 15,
|
||||
condition: "storm",
|
||||
}))
|
||||
|
||||
const alert = createAlertSource()
|
||||
|
||||
const engine = new FeedEngine().register(location).register(weather).register(alert)
|
||||
|
||||
const { items } = await engine.refresh()
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]!.type).toBe("alert") // priority 1.0
|
||||
expect(items[1]!.type).toBe("weather") // priority 0.5
|
||||
})
|
||||
|
||||
test("handles missing upstream context gracefully", async () => {
|
||||
const location: FeedSource = {
|
||||
id: "location",
|
||||
async fetchContext() {
|
||||
return {} // No location available
|
||||
},
|
||||
}
|
||||
|
||||
const weather = createWeatherSource()
|
||||
|
||||
const engine = new FeedEngine().register(location).register(weather)
|
||||
|
||||
const { context, items } = await engine.refresh()
|
||||
|
||||
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("captures errors from fetchContext", async () => {
|
||||
const failing: FeedSource = {
|
||||
id: "failing",
|
||||
async fetchContext() {
|
||||
throw new Error("Context fetch failed")
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine().register(failing)
|
||||
|
||||
const { errors } = await engine.refresh()
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.sourceId).toBe("failing")
|
||||
expect(errors[0]!.error.message).toBe("Context fetch failed")
|
||||
})
|
||||
|
||||
test("captures errors from fetchItems", async () => {
|
||||
const failing: FeedSource = {
|
||||
id: "failing",
|
||||
async fetchItems() {
|
||||
throw new Error("Items fetch failed")
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine().register(failing)
|
||||
|
||||
const { errors } = await engine.refresh()
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.sourceId).toBe("failing")
|
||||
expect(errors[0]!.error.message).toBe("Items fetch failed")
|
||||
})
|
||||
|
||||
test("continues after source error", async () => {
|
||||
const failing: FeedSource = {
|
||||
id: "failing",
|
||||
async fetchContext() {
|
||||
throw new Error("Failed")
|
||||
},
|
||||
}
|
||||
|
||||
const working: FeedSource = {
|
||||
id: "working",
|
||||
async fetchItems() {
|
||||
return [
|
||||
{
|
||||
id: "item-1",
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: {},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine().register(failing).register(working)
|
||||
|
||||
const { items, errors } = await engine.refresh()
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(items).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("currentContext", () => {
|
||||
test("returns initial context before refresh", () => {
|
||||
const engine = new FeedEngine()
|
||||
|
||||
const context = engine.currentContext()
|
||||
|
||||
expect(context.time).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test("returns accumulated context after refresh", async () => {
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
const engine = new FeedEngine().register(location)
|
||||
|
||||
await engine.refresh()
|
||||
|
||||
const context = engine.currentContext()
|
||||
expect(contextValue(context, LocationKey)).toEqual({ lat: 51.5, lng: -0.1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("subscribe", () => {
|
||||
test("returns unsubscribe function", () => {
|
||||
const engine = new FeedEngine()
|
||||
let callCount = 0
|
||||
|
||||
const unsubscribe = engine.subscribe(() => {
|
||||
callCount++
|
||||
})
|
||||
|
||||
unsubscribe()
|
||||
|
||||
// Subscriber should not be called after unsubscribe
|
||||
expect(callCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reactive updates", () => {
|
||||
test("start subscribes to onContextUpdate", async () => {
|
||||
const location = createLocationSource()
|
||||
const weather = createWeatherSource()
|
||||
|
||||
const engine = new FeedEngine().register(location).register(weather)
|
||||
|
||||
const results: Array<{ items: FeedItem[] }> = []
|
||||
engine.subscribe((result) => {
|
||||
results.push({ items: result.items })
|
||||
})
|
||||
|
||||
engine.start()
|
||||
|
||||
// Simulate location update
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
// Wait for async refresh
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0]!.items[0]!.type).toBe("weather")
|
||||
})
|
||||
|
||||
test("stop unsubscribes from all sources", async () => {
|
||||
const location = createLocationSource()
|
||||
|
||||
const engine = new FeedEngine().register(location)
|
||||
|
||||
let callCount = 0
|
||||
engine.subscribe(() => {
|
||||
callCount++
|
||||
})
|
||||
|
||||
engine.start()
|
||||
engine.stop()
|
||||
|
||||
// Simulate update after stop
|
||||
location.simulateUpdate({ lat: 1, lng: 1 })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(callCount).toBe(0)
|
||||
})
|
||||
|
||||
test("start is idempotent", () => {
|
||||
const location = createLocationSource()
|
||||
const engine = new FeedEngine().register(location)
|
||||
|
||||
// Should not throw or double-subscribe
|
||||
engine.start()
|
||||
engine.start()
|
||||
engine.stop()
|
||||
})
|
||||
})
|
||||
})
|
||||
335
packages/aris-core/src/feed-engine.ts
Normal file
335
packages/aris-core/src/feed-engine.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { Context } from "./context"
|
||||
import type { FeedItem } from "./feed"
|
||||
import type { FeedSource } from "./feed-source"
|
||||
|
||||
export interface SourceError {
|
||||
sourceId: string
|
||||
error: Error
|
||||
}
|
||||
|
||||
export interface FeedResult<TItem extends FeedItem = FeedItem> {
|
||||
context: Context
|
||||
items: TItem[]
|
||||
errors: SourceError[]
|
||||
}
|
||||
|
||||
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
||||
|
||||
interface SourceGraph {
|
||||
sources: Map<string, FeedSource>
|
||||
sorted: FeedSource[]
|
||||
dependents: Map<string, string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates FeedSources, managing the dependency graph and context flow.
|
||||
*
|
||||
* Sources declare dependencies on other sources. The engine:
|
||||
* - Validates the dependency graph (no missing deps, no cycles)
|
||||
* - Runs fetchContext() in topological order during refresh
|
||||
* - Runs fetchItems() on all sources with accumulated context
|
||||
* - Subscribes to reactive updates via onContextUpdate/onItemsUpdate
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const engine = new FeedEngine()
|
||||
* .register(locationSource)
|
||||
* .register(weatherSource)
|
||||
* .register(alertSource)
|
||||
*
|
||||
* // Pull-based refresh
|
||||
* const { context, items, errors } = await engine.refresh()
|
||||
*
|
||||
* // Reactive updates
|
||||
* engine.subscribe((result) => {
|
||||
* console.log(result.items)
|
||||
* })
|
||||
* engine.start()
|
||||
*
|
||||
* // Cleanup
|
||||
* engine.stop()
|
||||
* ```
|
||||
*/
|
||||
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
private sources = new Map<string, FeedSource>()
|
||||
private graph: SourceGraph | null = null
|
||||
private context: Context = { time: new Date() }
|
||||
private subscribers = new Set<FeedSubscriber<TItems>>()
|
||||
private cleanups: Array<() => void> = []
|
||||
private started = false
|
||||
|
||||
/**
|
||||
* Registers a FeedSource. Invalidates the cached graph.
|
||||
*/
|
||||
register<TItem extends FeedItem>(source: FeedSource<TItem>): FeedEngine<TItems | TItem> {
|
||||
this.sources.set(source.id, source)
|
||||
this.graph = null
|
||||
return this as FeedEngine<TItems | TItem>
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a FeedSource by ID. Invalidates the cached graph.
|
||||
*/
|
||||
unregister(sourceId: string): this {
|
||||
this.sources.delete(sourceId)
|
||||
this.graph = null
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the feed by running all sources in dependency order.
|
||||
* Calls fetchContext() then fetchItems() on each source.
|
||||
*/
|
||||
async refresh(): Promise<FeedResult<TItems>> {
|
||||
const graph = this.ensureGraph()
|
||||
const errors: SourceError[] = []
|
||||
|
||||
// Reset context with fresh time
|
||||
let context: Context = { time: new Date() }
|
||||
|
||||
// Run fetchContext in topological order
|
||||
for (const source of graph.sorted) {
|
||||
if (source.fetchContext) {
|
||||
try {
|
||||
const update = await source.fetchContext(context)
|
||||
context = { ...context, ...update }
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
sourceId: source.id,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run fetchItems on all sources
|
||||
const items: FeedItem[] = []
|
||||
for (const source of graph.sorted) {
|
||||
if (source.fetchItems) {
|
||||
try {
|
||||
const sourceItems = await source.fetchItems(context)
|
||||
items.push(...sourceItems)
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
sourceId: source.id,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority descending
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
this.context = context
|
||||
|
||||
return { context, items: items as TItems[], errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to feed updates. Returns unsubscribe function.
|
||||
*/
|
||||
subscribe(callback: FeedSubscriber<TItems>): () => void {
|
||||
this.subscribers.add(callback)
|
||||
return () => {
|
||||
this.subscribers.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reactive subscriptions on all sources.
|
||||
* Sources with onContextUpdate will trigger re-computation of dependents.
|
||||
*/
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
|
||||
this.started = true
|
||||
const graph = this.ensureGraph()
|
||||
|
||||
for (const source of graph.sorted) {
|
||||
if (source.onContextUpdate) {
|
||||
const cleanup = source.onContextUpdate(
|
||||
(update) => {
|
||||
this.handleContextUpdate(source.id, update)
|
||||
},
|
||||
() => this.context,
|
||||
)
|
||||
this.cleanups.push(cleanup)
|
||||
}
|
||||
|
||||
if (source.onItemsUpdate) {
|
||||
const cleanup = source.onItemsUpdate(
|
||||
() => {
|
||||
this.scheduleRefresh()
|
||||
},
|
||||
() => this.context,
|
||||
)
|
||||
this.cleanups.push(cleanup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops all reactive subscriptions.
|
||||
*/
|
||||
stop(): void {
|
||||
this.started = false
|
||||
for (const cleanup of this.cleanups) {
|
||||
cleanup()
|
||||
}
|
||||
this.cleanups = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current accumulated context.
|
||||
*/
|
||||
currentContext(): Context {
|
||||
return this.context
|
||||
}
|
||||
|
||||
private ensureGraph(): SourceGraph {
|
||||
if (!this.graph) {
|
||||
this.graph = buildGraph(Array.from(this.sources.values()))
|
||||
}
|
||||
return this.graph
|
||||
}
|
||||
|
||||
private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
|
||||
this.context = { ...this.context, ...update, time: new Date() }
|
||||
|
||||
// Re-run dependents and notify
|
||||
this.refreshDependents(sourceId)
|
||||
}
|
||||
|
||||
private async refreshDependents(sourceId: string): Promise<void> {
|
||||
const graph = this.ensureGraph()
|
||||
const toRefresh = this.collectDependents(sourceId, graph)
|
||||
|
||||
// Re-run fetchContext for dependents in order
|
||||
for (const id of toRefresh) {
|
||||
const source = graph.sources.get(id)
|
||||
if (source?.fetchContext) {
|
||||
try {
|
||||
const update = await source.fetchContext(this.context)
|
||||
this.context = { ...this.context, ...update }
|
||||
} catch {
|
||||
// Errors during reactive updates are logged but don't stop propagation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect items from all sources
|
||||
const items: FeedItem[] = []
|
||||
const errors: SourceError[] = []
|
||||
|
||||
for (const source of graph.sorted) {
|
||||
if (source.fetchItems) {
|
||||
try {
|
||||
const sourceItems = await source.fetchItems(this.context)
|
||||
items.push(...sourceItems)
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
sourceId: source.id,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
this.notifySubscribers({ context: this.context, items: items as TItems[], errors })
|
||||
}
|
||||
|
||||
private collectDependents(sourceId: string, graph: SourceGraph): string[] {
|
||||
const result: string[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
const collect = (id: string): void => {
|
||||
const deps = graph.dependents.get(id) ?? []
|
||||
for (const dep of deps) {
|
||||
if (!visited.has(dep)) {
|
||||
visited.add(dep)
|
||||
result.push(dep)
|
||||
collect(dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(sourceId)
|
||||
|
||||
// Return in topological order
|
||||
return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id)
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
// Simple immediate refresh for now - could add debouncing later
|
||||
this.refresh().then((result) => {
|
||||
this.notifySubscribers(result)
|
||||
})
|
||||
}
|
||||
|
||||
private notifySubscribers(result: FeedResult<TItems>): void {
|
||||
this.subscribers.forEach((callback) => {
|
||||
try {
|
||||
callback(result)
|
||||
} catch {
|
||||
// Subscriber errors shouldn't break other subscribers
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -8,20 +8,35 @@ export type { FeedItem } from "./feed"
|
||||
// Feed Source
|
||||
export type { FeedSource } from "./feed-source"
|
||||
|
||||
// Feed Engine
|
||||
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||
export { FeedEngine } from "./feed-engine"
|
||||
|
||||
// =============================================================================
|
||||
// DEPRECATED - Use FeedSource + FeedEngine instead
|
||||
// =============================================================================
|
||||
|
||||
// Data Source (deprecated - use FeedSource)
|
||||
export type { DataSource } from "./data-source"
|
||||
|
||||
// Context Provider
|
||||
// Context Provider (deprecated - use FeedSource)
|
||||
export type { ContextProvider } from "./context-provider"
|
||||
|
||||
// Context Bridge
|
||||
// Context Bridge (deprecated - use FeedEngine)
|
||||
export type { ProviderError, RefreshResult } from "./context-bridge"
|
||||
export { ContextBridge } from "./context-bridge"
|
||||
|
||||
// Reconciler
|
||||
export type { ReconcileResult, ReconcilerConfig, SourceError } from "./reconciler"
|
||||
// Reconciler (deprecated - use FeedEngine)
|
||||
export type {
|
||||
ReconcileResult,
|
||||
ReconcilerConfig,
|
||||
SourceError as ReconcilerSourceError,
|
||||
} from "./reconciler"
|
||||
export { Reconciler } from "./reconciler"
|
||||
|
||||
// Feed Controller
|
||||
export type { FeedControllerConfig, FeedSubscriber } from "./feed-controller"
|
||||
// Feed Controller (deprecated - use FeedEngine)
|
||||
export type {
|
||||
FeedControllerConfig,
|
||||
FeedSubscriber as FeedControllerSubscriber,
|
||||
} from "./feed-controller"
|
||||
export { FeedController } from "./feed-controller"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export { TflDataSource } from "./data-source.ts"
|
||||
export { TflApi, type ITflApi, type TflLineStatus } from "./tfl-api.ts"
|
||||
export type {
|
||||
TflAlertData,
|
||||
TflAlertFeedItem,
|
||||
TflAlertSeverity,
|
||||
TflDataSourceConfig,
|
||||
TflDataSourceOptions,
|
||||
TflLineId,
|
||||
StationLocation,
|
||||
} from "./types.ts"
|
||||
@@ -1,208 +0,0 @@
|
||||
import type { Context } from "@aris/core"
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ITflApi, TflLineStatus } from "./tfl-api.ts"
|
||||
import type { StationLocation, TflLineId } from "./types.ts"
|
||||
|
||||
import fixtures from "../fixtures/tfl-responses.json"
|
||||
import { TflDataSource } from "./data-source.ts"
|
||||
|
||||
// Mock API that returns fixture data
|
||||
class FixtureTflApi implements ITflApi {
|
||||
async fetchLineStatuses(_lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||
const statuses: TflLineStatus[] = []
|
||||
|
||||
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
||||
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
||||
const severityCode = status.statusSeverity as number
|
||||
const severity = this.mapSeverity(severityCode)
|
||||
if (severity) {
|
||||
statuses.push({
|
||||
lineId: line.id as TflLineId,
|
||||
lineName: line.name as string,
|
||||
severity,
|
||||
description: (status.reason as string) ?? (status.statusSeverityDescription as string),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
async fetchStations(): Promise<StationLocation[]> {
|
||||
const stationMap = new Map<string, StationLocation>()
|
||||
|
||||
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
||||
for (const stop of stops as Record<string, unknown>[]) {
|
||||
const id = stop.naptanId as string
|
||||
const existing = stationMap.get(id)
|
||||
if (existing) {
|
||||
if (!existing.lines.includes(lineId as TflLineId)) {
|
||||
existing.lines.push(lineId as TflLineId)
|
||||
}
|
||||
} else {
|
||||
stationMap.set(id, {
|
||||
id,
|
||||
name: stop.commonName as string,
|
||||
lat: stop.lat as number,
|
||||
lng: stop.lon as number,
|
||||
lines: [lineId as TflLineId],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(stationMap.values())
|
||||
}
|
||||
|
||||
private mapSeverity(code: number): "minor-delays" | "major-delays" | "closure" | null {
|
||||
const map: Record<number, "minor-delays" | "major-delays" | "closure" | null> = {
|
||||
1: "closure",
|
||||
2: "closure",
|
||||
3: "closure",
|
||||
4: "closure",
|
||||
5: "closure",
|
||||
6: "major-delays",
|
||||
7: "major-delays",
|
||||
8: "major-delays",
|
||||
9: "minor-delays",
|
||||
10: null,
|
||||
}
|
||||
return map[code] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const createContext = (location?: { lat: number; lng: number }): Context => ({
|
||||
time: new Date("2026-01-15T12:00:00Z"),
|
||||
location: location ? { ...location, accuracy: 10 } : undefined,
|
||||
})
|
||||
|
||||
describe("TfL Feed Items (using fixture data)", () => {
|
||||
const api = new FixtureTflApi()
|
||||
|
||||
test("query returns feed items array", async () => {
|
||||
const dataSource = new TflDataSource(api)
|
||||
const items = await dataSource.query(createContext(), {})
|
||||
expect(Array.isArray(items)).toBe(true)
|
||||
})
|
||||
|
||||
test("feed items have correct base structure", async () => {
|
||||
const dataSource = new TflDataSource(api)
|
||||
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
||||
|
||||
for (const item of items) {
|
||||
expect(typeof item.id).toBe("string")
|
||||
expect(item.id).toMatch(/^tfl-alert-/)
|
||||
expect(item.type).toBe("tfl-alert")
|
||||
expect(typeof item.priority).toBe("number")
|
||||
expect(item.timestamp).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
|
||||
test("feed items have correct data structure", async () => {
|
||||
const dataSource = new TflDataSource(api)
|
||||
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
||||
|
||||
for (const item of items) {
|
||||
expect(typeof item.data.line).toBe("string")
|
||||
expect(typeof item.data.lineName).toBe("string")
|
||||
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
|
||||
expect(typeof item.data.description).toBe("string")
|
||||
expect(
|
||||
item.data.closestStationDistance === null ||
|
||||
typeof item.data.closestStationDistance === "number",
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("feed item ids are unique", async () => {
|
||||
const dataSource = new TflDataSource(api)
|
||||
const items = await dataSource.query(createContext(), {})
|
||||
|
||||
const ids = items.map((item) => item.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
test("feed items are sorted by priority descending", async () => {
|
||||
const dataSource = new TflDataSource(api)
|
||||
const items = await dataSource.query(createContext(), {})
|
||||
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const prev = items[i - 1]!
|
||||
const curr = items[i]!
|
||||
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
||||
}
|
||||
})
|
||||
|
||||
test("priority values match severity levels", async () => {
|
||||
const dataSource = new TflDataSource(api)
|
||||
const items = await dataSource.query(createContext(), {})
|
||||
|
||||
const severityPriority: Record<string, number> = {
|
||||
closure: 100,
|
||||
"major-delays": 80,
|
||||
"minor-delays": 60,
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
||||
}
|
||||
})
|
||||
|
||||
test("closestStationDistance is number when location provided", async () => {
|
||||
const dataSource = new TflDataSource(api)
|
||||
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
|
||||
|
||||
for (const item of items) {
|
||||
expect(typeof item.data.closestStationDistance).toBe("number")
|
||||
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test("closestStationDistance is null when no location provided", async () => {
|
||||
const dataSource = new TflDataSource(api)
|
||||
const items = await dataSource.query(createContext(), {})
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.data.closestStationDistance).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("TfL Fixture Data Shape", () => {
|
||||
test("fixtures have expected structure", () => {
|
||||
expect(typeof fixtures.fetchedAt).toBe("string")
|
||||
expect(Array.isArray(fixtures.lineStatuses)).toBe(true)
|
||||
expect(typeof fixtures.stopPoints).toBe("object")
|
||||
})
|
||||
|
||||
test("line statuses have required fields", () => {
|
||||
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
||||
expect(typeof line.id).toBe("string")
|
||||
expect(typeof line.name).toBe("string")
|
||||
expect(Array.isArray(line.lineStatuses)).toBe(true)
|
||||
|
||||
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
||||
expect(typeof status.statusSeverity).toBe("number")
|
||||
expect(typeof status.statusSeverityDescription).toBe("string")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("stop points have required fields", () => {
|
||||
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
||||
expect(typeof lineId).toBe("string")
|
||||
expect(Array.isArray(stops)).toBe(true)
|
||||
|
||||
for (const stop of stops as Record<string, unknown>[]) {
|
||||
expect(typeof stop.naptanId).toBe("string")
|
||||
expect(typeof stop.commonName).toBe("string")
|
||||
expect(typeof stop.lat).toBe("number")
|
||||
expect(typeof stop.lon).toBe("number")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { FeedItem } from "@aris/core"
|
||||
|
||||
import type { TflLineId } from "./tfl-api.ts"
|
||||
|
||||
export type { TflLineId } from "./tfl-api.ts"
|
||||
|
||||
export type TflAlertSeverity = "minor-delays" | "major-delays" | "closure"
|
||||
|
||||
export interface TflAlertData extends Record<string, unknown> {
|
||||
line: TflLineId
|
||||
lineName: string
|
||||
severity: TflAlertSeverity
|
||||
description: string
|
||||
closestStationDistance: number | null
|
||||
}
|
||||
|
||||
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
|
||||
|
||||
export interface TflDataSourceConfig {
|
||||
lines?: TflLineId[]
|
||||
}
|
||||
|
||||
export interface TflDataSourceOptions {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface StationLocation {
|
||||
id: string
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
lines: TflLineId[]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@aris/data-source-tfl",
|
||||
"name": "@aris/source-tfl",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"@aris/source-location": "workspace:*",
|
||||
"arktype": "^2.1.0"
|
||||
}
|
||||
}
|
||||
11
packages/aris-source-tfl/src/index.ts
Normal file
11
packages/aris-source-tfl/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { TflSource } from "./tfl-source.ts"
|
||||
export { TflApi } from "./tfl-api.ts"
|
||||
export type { TflLineId } from "./tfl-api.ts"
|
||||
export type {
|
||||
StationLocation,
|
||||
TflAlertData,
|
||||
TflAlertFeedItem,
|
||||
TflAlertSeverity,
|
||||
TflLineStatus,
|
||||
TflSourceOptions,
|
||||
} from "./types.ts"
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type } from "arktype"
|
||||
|
||||
import type { StationLocation, TflAlertSeverity } from "./types.ts"
|
||||
import type { StationLocation, TflAlertSeverity, TflLineStatus } from "./types.ts"
|
||||
|
||||
const TFL_API_BASE = "https://api.tfl.gov.uk"
|
||||
|
||||
@@ -50,19 +50,7 @@ const SEVERITY_MAP: Record<number, TflAlertSeverity | null> = {
|
||||
20: null, // Service Closed
|
||||
}
|
||||
|
||||
export interface TflLineStatus {
|
||||
lineId: TflLineId
|
||||
lineName: string
|
||||
severity: TflAlertSeverity
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ITflApi {
|
||||
fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]>
|
||||
fetchStations(): Promise<StationLocation[]>
|
||||
}
|
||||
|
||||
export class TflApi implements ITflApi {
|
||||
export class TflApi {
|
||||
private apiKey: string
|
||||
private stationsCache: StationLocation[] | null = null
|
||||
|
||||
243
packages/aris-source-tfl/src/tfl-source.test.ts
Normal file
243
packages/aris-source-tfl/src/tfl-source.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import type { Context } from "@aris/core"
|
||||
|
||||
import { LocationKey, type Location } from "@aris/source-location"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type {
|
||||
ITflApi,
|
||||
StationLocation,
|
||||
TflAlertSeverity,
|
||||
TflLineId,
|
||||
TflLineStatus,
|
||||
} from "./types.ts"
|
||||
|
||||
import fixtures from "../fixtures/tfl-responses.json"
|
||||
import { TflSource } from "./tfl-source.ts"
|
||||
|
||||
// Mock API that returns fixture data
|
||||
class FixtureTflApi implements ITflApi {
|
||||
async fetchLineStatuses(_lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||
const statuses: TflLineStatus[] = []
|
||||
|
||||
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
||||
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
||||
const severityCode = status.statusSeverity as number
|
||||
const severity = this.mapSeverity(severityCode)
|
||||
if (severity) {
|
||||
statuses.push({
|
||||
lineId: line.id as TflLineId,
|
||||
lineName: line.name as string,
|
||||
severity,
|
||||
description: (status.reason as string) ?? (status.statusSeverityDescription as string),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
async fetchStations(): Promise<StationLocation[]> {
|
||||
const stationMap = new Map<string, StationLocation>()
|
||||
|
||||
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
||||
for (const stop of stops as Record<string, unknown>[]) {
|
||||
const id = stop.naptanId as string
|
||||
const existing = stationMap.get(id)
|
||||
if (existing) {
|
||||
if (!existing.lines.includes(lineId as TflLineId)) {
|
||||
existing.lines.push(lineId as TflLineId)
|
||||
}
|
||||
} else {
|
||||
stationMap.set(id, {
|
||||
id,
|
||||
name: stop.commonName as string,
|
||||
lat: stop.lat as number,
|
||||
lng: stop.lon as number,
|
||||
lines: [lineId as TflLineId],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(stationMap.values())
|
||||
}
|
||||
|
||||
private mapSeverity(code: number): TflAlertSeverity | null {
|
||||
const map: Record<number, TflAlertSeverity | null> = {
|
||||
1: "closure",
|
||||
2: "closure",
|
||||
3: "closure",
|
||||
4: "closure",
|
||||
5: "closure",
|
||||
6: "major-delays",
|
||||
7: "major-delays",
|
||||
8: "major-delays",
|
||||
9: "minor-delays",
|
||||
10: null,
|
||||
}
|
||||
return map[code] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(location?: Location): Context {
|
||||
const ctx: Context = { time: new Date("2026-01-15T12:00:00Z") }
|
||||
if (location) {
|
||||
ctx[LocationKey] = location
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe("TflSource", () => {
|
||||
const api = new FixtureTflApi()
|
||||
|
||||
describe("interface", () => {
|
||||
test("has correct id", () => {
|
||||
const source = new TflSource({ client: api })
|
||||
expect(source.id).toBe("tfl")
|
||||
})
|
||||
|
||||
test("depends on location", () => {
|
||||
const source = new TflSource({ client: api })
|
||||
expect(source.dependencies).toEqual(["location"])
|
||||
})
|
||||
|
||||
test("implements fetchItems", () => {
|
||||
const source = new TflSource({ client: api })
|
||||
expect(source.fetchItems).toBeDefined()
|
||||
})
|
||||
|
||||
test("throws if neither client nor apiKey provided", () => {
|
||||
expect(() => new TflSource({})).toThrow("Either client or apiKey must be provided")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchItems", () => {
|
||||
test("returns feed items array", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
expect(Array.isArray(items)).toBe(true)
|
||||
})
|
||||
|
||||
test("feed items have correct base structure", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||
const items = await source.fetchItems(createContext(location))
|
||||
|
||||
for (const item of items) {
|
||||
expect(typeof item.id).toBe("string")
|
||||
expect(item.id).toMatch(/^tfl-alert-/)
|
||||
expect(item.type).toBe("tfl-alert")
|
||||
expect(typeof item.priority).toBe("number")
|
||||
expect(item.timestamp).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
|
||||
test("feed items have correct data structure", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||
const items = await source.fetchItems(createContext(location))
|
||||
|
||||
for (const item of items) {
|
||||
expect(typeof item.data.line).toBe("string")
|
||||
expect(typeof item.data.lineName).toBe("string")
|
||||
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
|
||||
expect(typeof item.data.description).toBe("string")
|
||||
expect(
|
||||
item.data.closestStationDistance === null ||
|
||||
typeof item.data.closestStationDistance === "number",
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("feed item ids are unique", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const ids = items.map((item) => item.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
test("feed items are sorted by priority descending", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const prev = items[i - 1]!
|
||||
const curr = items[i]!
|
||||
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
|
||||
}
|
||||
})
|
||||
|
||||
test("priority values match severity levels", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
const severityPriority: Record<string, number> = {
|
||||
closure: 1.0,
|
||||
"major-delays": 0.8,
|
||||
"minor-delays": 0.6,
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.priority).toBe(severityPriority[item.data.severity]!)
|
||||
}
|
||||
})
|
||||
|
||||
test("closestStationDistance is number when location provided", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
|
||||
const items = await source.fetchItems(createContext(location))
|
||||
|
||||
for (const item of items) {
|
||||
expect(typeof item.data.closestStationDistance).toBe("number")
|
||||
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test("closestStationDistance is null when no location provided", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
for (const item of items) {
|
||||
expect(item.data.closestStationDistance).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("TfL Fixture Data Shape", () => {
|
||||
test("fixtures have expected structure", () => {
|
||||
expect(typeof fixtures.fetchedAt).toBe("string")
|
||||
expect(Array.isArray(fixtures.lineStatuses)).toBe(true)
|
||||
expect(typeof fixtures.stopPoints).toBe("object")
|
||||
})
|
||||
|
||||
test("line statuses have required fields", () => {
|
||||
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
|
||||
expect(typeof line.id).toBe("string")
|
||||
expect(typeof line.name).toBe("string")
|
||||
expect(Array.isArray(line.lineStatuses)).toBe(true)
|
||||
|
||||
for (const status of line.lineStatuses as Record<string, unknown>[]) {
|
||||
expect(typeof status.statusSeverity).toBe("number")
|
||||
expect(typeof status.statusSeverityDescription).toBe("string")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("stop points have required fields", () => {
|
||||
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
|
||||
expect(typeof lineId).toBe("string")
|
||||
expect(Array.isArray(stops)).toBe(true)
|
||||
|
||||
for (const stop of stops as Record<string, unknown>[]) {
|
||||
expect(typeof stop.naptanId).toBe("string")
|
||||
expect(typeof stop.commonName).toBe("string")
|
||||
expect(typeof stop.lat).toBe("number")
|
||||
expect(typeof stop.lon).toBe("number")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,104 @@
|
||||
import type { Context, DataSource } from "@aris/core"
|
||||
import type { Context, FeedSource } from "@aris/core"
|
||||
|
||||
import { contextValue } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
|
||||
import type {
|
||||
ITflApi,
|
||||
StationLocation,
|
||||
TflAlertData,
|
||||
TflAlertFeedItem,
|
||||
TflAlertSeverity,
|
||||
TflDataSourceConfig,
|
||||
TflDataSourceOptions,
|
||||
TflLineId,
|
||||
TflSourceOptions,
|
||||
} from "./types.ts"
|
||||
|
||||
import { TflApi, type ITflApi } from "./tfl-api.ts"
|
||||
import { TflApi } from "./tfl-api.ts"
|
||||
|
||||
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
||||
closure: 100,
|
||||
"major-delays": 80,
|
||||
"minor-delays": 60,
|
||||
closure: 1.0,
|
||||
"major-delays": 0.8,
|
||||
"minor-delays": 0.6,
|
||||
}
|
||||
|
||||
/**
|
||||
* A FeedSource that provides TfL (Transport for London) service alerts.
|
||||
*
|
||||
* Depends on location source for proximity-based sorting. Produces feed items
|
||||
* for tube, overground, and Elizabeth line disruptions.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const tflSource = new TflSource({
|
||||
* apiKey: process.env.TFL_API_KEY!,
|
||||
* lines: ["northern", "victoria", "jubilee"],
|
||||
* })
|
||||
*
|
||||
* const engine = new FeedEngine()
|
||||
* .register(locationSource)
|
||||
* .register(tflSource)
|
||||
*
|
||||
* const { items } = await engine.refresh()
|
||||
* ```
|
||||
*/
|
||||
export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||
readonly id = "tfl"
|
||||
readonly dependencies = ["location"]
|
||||
|
||||
private readonly client: ITflApi
|
||||
private readonly lines?: TflLineId[]
|
||||
|
||||
constructor(options: TflSourceOptions) {
|
||||
if (!options.client && !options.apiKey) {
|
||||
throw new Error("Either client or apiKey must be provided")
|
||||
}
|
||||
this.client = options.client ?? new TflApi(options.apiKey!)
|
||||
this.lines = options.lines
|
||||
}
|
||||
|
||||
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
|
||||
const [statuses, stations] = await Promise.all([
|
||||
this.client.fetchLineStatuses(this.lines),
|
||||
this.client.fetchStations(),
|
||||
])
|
||||
|
||||
const location = contextValue(context, LocationKey)
|
||||
|
||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||
const closestStationDistance = location
|
||||
? findClosestStationDistance(status.lineId, stations, location.lat, location.lng)
|
||||
: null
|
||||
|
||||
const data: TflAlertData = {
|
||||
line: status.lineId,
|
||||
lineName: status.lineName,
|
||||
severity: status.severity,
|
||||
description: status.description,
|
||||
closestStationDistance,
|
||||
}
|
||||
|
||||
return {
|
||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||
type: "tfl-alert",
|
||||
priority: SEVERITY_PRIORITY[status.severity],
|
||||
timestamp: context.time,
|
||||
data,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by severity (desc), then by proximity (asc) if location available
|
||||
items.sort((a, b) => {
|
||||
if (b.priority !== a.priority) {
|
||||
return b.priority - a.priority
|
||||
}
|
||||
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
||||
return a.data.closestStationDistance - b.data.closestStationDistance
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
@@ -51,65 +134,3 @@ function findClosestStationDistance(
|
||||
|
||||
return minDistance
|
||||
}
|
||||
|
||||
export class TflDataSource implements DataSource<TflAlertFeedItem, TflDataSourceConfig> {
|
||||
readonly type = "tfl-alert"
|
||||
private api: ITflApi
|
||||
|
||||
constructor(options: TflDataSourceOptions)
|
||||
constructor(api: ITflApi)
|
||||
constructor(optionsOrApi: TflDataSourceOptions | ITflApi) {
|
||||
if ("fetchLineStatuses" in optionsOrApi) {
|
||||
this.api = optionsOrApi
|
||||
} else {
|
||||
this.api = new TflApi(optionsOrApi.apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
async query(context: Context, config: TflDataSourceConfig): Promise<TflAlertFeedItem[]> {
|
||||
const [statuses, stations] = await Promise.all([
|
||||
this.api.fetchLineStatuses(config.lines),
|
||||
this.api.fetchStations(),
|
||||
])
|
||||
|
||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||
const closestStationDistance = context.location
|
||||
? findClosestStationDistance(
|
||||
status.lineId,
|
||||
stations,
|
||||
context.location.lat,
|
||||
context.location.lng,
|
||||
)
|
||||
: null
|
||||
|
||||
const data: TflAlertData = {
|
||||
line: status.lineId,
|
||||
lineName: status.lineName,
|
||||
severity: status.severity,
|
||||
description: status.description,
|
||||
closestStationDistance,
|
||||
}
|
||||
|
||||
return {
|
||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||
type: this.type,
|
||||
priority: SEVERITY_PRIORITY[status.severity],
|
||||
timestamp: context.time,
|
||||
data,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by severity (desc), then by proximity (asc) if location available
|
||||
items.sort((a, b) => {
|
||||
if (b.priority !== a.priority) {
|
||||
return b.priority - a.priority
|
||||
}
|
||||
if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) {
|
||||
return a.data.closestStationDistance - b.data.closestStationDistance
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
50
packages/aris-source-tfl/src/types.ts
Normal file
50
packages/aris-source-tfl/src/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { FeedItem } from "@aris/core"
|
||||
|
||||
import type { TflLineId } from "./tfl-api.ts"
|
||||
|
||||
export type { TflLineId } from "./tfl-api.ts"
|
||||
|
||||
export const TflAlertSeverity = {
|
||||
MinorDelays: "minor-delays",
|
||||
MajorDelays: "major-delays",
|
||||
Closure: "closure",
|
||||
} as const
|
||||
|
||||
export type TflAlertSeverity = (typeof TflAlertSeverity)[keyof typeof TflAlertSeverity]
|
||||
|
||||
export interface TflAlertData extends Record<string, unknown> {
|
||||
line: TflLineId
|
||||
lineName: string
|
||||
severity: TflAlertSeverity
|
||||
description: string
|
||||
closestStationDistance: number | null
|
||||
}
|
||||
|
||||
export type TflAlertFeedItem = FeedItem<"tfl-alert", TflAlertData>
|
||||
|
||||
export interface TflSourceOptions {
|
||||
apiKey?: string
|
||||
client?: ITflApi
|
||||
/** Lines to monitor. Defaults to all lines. */
|
||||
lines?: TflLineId[]
|
||||
}
|
||||
|
||||
export interface StationLocation {
|
||||
id: string
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
lines: TflLineId[]
|
||||
}
|
||||
|
||||
export interface ITflApi {
|
||||
fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]>
|
||||
fetchStations(): Promise<StationLocation[]>
|
||||
}
|
||||
|
||||
export interface TflLineStatus {
|
||||
lineId: TflLineId
|
||||
lineName: string
|
||||
severity: TflAlertSeverity
|
||||
description: string
|
||||
}
|
||||
Reference in New Issue
Block a user