Compare commits

..

30 Commits

Author SHA1 Message Date
54e4b0dcf7 feat(backend): add TflService
Manages per-user TflSource instances with individual line
configuration. Implements FeedSourceProvider so it can be
wired into FeedEngineService.

Adds TflSource.setLines() so line config can be mutated
in place, keeping engine references valid.

Also exports ITflApi from @aris/source-tfl for testability.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-13 19:38:21 +00:00
d1102fe1ac Merge pull request #21 from kennethnym/feat/feed-engine-service
feat(backend): add FeedEngineService
2026-01-25 23:16:08 +00:00
db0c57f04b feat(backend): add FeedEngineService
Manages FeedEngine instances per user with auto-registration of
sources from FeedSourceProvider implementations.

- Add FeedSourceProvider interface
- Add FeedEngineService with providers array injection
- Update LocationService to implement FeedSourceProvider

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 23:12:48 +00:00
9e3fe2ea16 Merge pull request #20 from kennethnym/feat/location-router
feat(backend): add location router with tRPC factory pattern
2026-01-25 23:00:03 +00:00
949b7c8571 feat(backend): add location router with tRPC factory pattern
- Add createLocationRouter with location.update mutation
- Refactor tRPC to factory pattern (createTRPC, createTRPCRouter)
- Protected procedure by default (all routes require auth)
- Replace zod with arktype for input validation
- Wire location router in main() with dependency injection

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 22:58:32 +00:00
bd6cc3c963 Merge pull request #19 from kennethnym/feat/trpc
feat(backend): add tRPC with Hono adapter
2026-01-25 22:26:41 +00:00
aff9464245 feat(backend): add tRPC with Hono adapter
- Add @trpc/server, @hono/trpc-server, zod dependencies
- Create tRPC context with BetterAuth session
- Create router with publicProcedure and protectedProcedure
- Mount tRPC at /trpc/* via Hono adapter

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 22:19:05 +00:00
0db6cae82b Merge pull request #18 from kennethnym/feat/location-service
feat(backend): add LocationService
2026-01-25 19:41:26 +00:00
faad9e9736 feat(backend): add LocationService
- Add LocationService to manage LocationSource per user
- Add UserNotFoundError for generic user-related errors
- updateUserLocation throws if source not initialized

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 19:39:55 +00:00
da2c1b9ee7 Merge pull request #17 from kennethnym/feat/auth
feat(backend): add BetterAuth email/password authentication
2026-01-25 16:29:41 +00:00
c10c8a553a feat(backend): add BetterAuth email/password authentication
- Add PostgreSQL connection (src/db.ts)
- Configure BetterAuth with email/password (src/auth/index.ts)
- Add session middleware for route protection (src/auth/session-middleware.ts)
- Add registerAuthHandlers for mounting auth routes (src/auth/http.ts)
- Rename index.ts to server.ts
- Add .env.example with required environment variables

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 16:21:00 +00:00
fffcccc227 Merge pull request #16 from kennethnym/feat/aris-backend
feat(backend): init aris-backend with Hono
2026-01-25 14:55:17 +00:00
b744af9c51 feat(backend): init aris-backend with Hono
- Add apps/aris-backend package with Hono server
- Add /health endpoint
- Add backend-spec.md with design decisions

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 14:54:08 +00:00
13300fb6a6 Merge pull request #15 from kennethnym/feat/source-tfl
refactor: migrate aris-data-source-tfl to aris-source-tfl
2026-01-25 14:23:26 +00:00
66ee44b470 refactor: migrate aris-data-source-tfl to aris-source-tfl
Migrates TFL package from old DataSource interface to new FeedSource
interface for use with FeedEngine.

Changes:
- Rename package from @aris/data-source-tfl to @aris/source-tfl
- Replace TflDataSource class with TflSource implementing FeedSource
- Add dependency on @aris/source-location for LocationKey
- Use normalized priority values (0-1) instead of arbitrary numbers
- Update tests for FeedSource interface
- Update README.md with new package name

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 14:20:13 +00:00
1893c516f3 Merge pull request #14 from kennethnym/feat/feed-engine
feat(core): add FeedEngine for FeedSource orchestration
2026-01-24 23:07:41 +00:00
181160b018 feat(core): add FeedEngine for FeedSource orchestration
Introduces FeedEngine that consumes FeedSource instances and manages
the dependency graph for context flow and item collection.

- Validates dependency graph (missing deps, circular references)
- Topologically sorts sources for execution order
- Runs fetchContext() in dependency order, accumulating context
- Runs fetchItems() on all sources with final context
- Supports reactive updates via onContextUpdate/onItemsUpdate
- Graceful error handling (continues after source failures)

Marks DataSource, ContextProvider, ContextBridge, Reconciler, and
FeedController as deprecated in favor of FeedSource + FeedEngine.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-24 22:42:00 +00:00
559f82ce96 Merge pull request #13 from kennethnym/feat/source-weatherkit
feat: add @aris/source-weatherkit package
2026-01-19 00:56:32 +00:00
5e040470c7 feat: add @aris/source-weatherkit package
Implements FeedSource for WeatherKit API. Depends on location source,
provides weather context for downstream sources, and produces weather
feed items.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-19 00:50:13 +00:00
c2f2aeec1d Merge pull request #12 from kennethnym/feat/source-location
feat(source-location): add LocationSource for push-based location context
2026-01-19 00:38:28 +00:00
75ce06d39b feat(source-location): add LocationSource for push-based location context
Implements FeedSource interface. Accepts external location pushes,
provides context to downstream sources, does not produce feed items.

Supports configurable history size.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-19 00:37:35 +00:00
a7b6232058 Merge pull request #11 from kennethnym/fix/core-package-json-paths
fix(core): correct main and types paths in package.json
2026-01-19 00:29:50 +00:00
5df3dbd1b5 fix(core): correct main and types paths in package.json
Paths pointed to index.ts but actual file is at src/index.ts.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-19 00:08:04 +00:00
b7c7bcfc7c Merge pull request #9 from kennethnym/feat/feed-source-interface
feat(core): add FeedSource interface
2026-01-18 23:49:31 +00:00
9a47dda767 test(core): remove legacy integration tests
Tests were for DataSource/ContextProvider/ContextBridge which are now
deprecated in favor of FeedSource.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 23:46:38 +00:00
286a933d1e test(core): add FeedSource integration tests
Tests graph validation (dependency existence, cycle detection, topological
sort) and refresh behavior (context accumulation, item collection).

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 23:45:05 +00:00
1d9de2851a feat(core): add FeedSource interface
Unifies DataSource and ContextProvider into a single interface that
forms a dependency graph. Sources declare dependencies on other sources
and can provide context, feed items, or both.

Deprecates DataSource, ContextProvider, and ContextBridge.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 23:32:47 +00:00
80192c6dc1 Merge pull request #8 from kennethnym/docs/gpg-signing-instruction
docs: add GPG signing instruction to AGENTS.md
2026-01-18 20:41:06 +00:00
0eb77b73c6 docs: add GPG signing instruction to AGENTS.md
Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 20:39:26 +00:00
dfce846c9a Merge pull request #7 from kennethnym/feat/feed-controller-orchestration
feat(core): add FeedController orchestration layer
2026-01-18 20:30:27 +00:00
56 changed files with 4709 additions and 884 deletions

View File

@@ -39,3 +39,4 @@ Use Bun exclusively. Do not use npm or yarn.
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
- 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`

View File

@@ -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
```

View File

@@ -0,0 +1,8 @@
# PostgreSQL connection string
DATABASE_URL=postgresql://user:password@localhost:5432/aris
# BetterAuth secret (min 32 chars, generate with: openssl rand -base64 32)
BETTER_AUTH_SECRET=
# Base URL of the backend
BETTER_AUTH_URL=http://localhost:3000

View File

@@ -0,0 +1,26 @@
{
"name": "@aris/backend",
"version": "0.0.0",
"type": "module",
"main": "src/server.ts",
"scripts": {
"dev": "bun run --watch src/server.ts",
"start": "bun run src/server.ts",
"test": "bun test src/"
},
"dependencies": {
"@aris/core": "workspace:*",
"@aris/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@hono/trpc-server": "^0.3",
"@trpc/server": "^11",
"arktype": "^2.1.29",
"better-auth": "^1",
"hono": "^4",
"pg": "^8"
},
"devDependencies": {
"@types/pg": "^8"
}
}

View File

@@ -0,0 +1,7 @@
import type { Hono } from "hono"
import { auth } from "./index.ts"
export function registerAuthHandlers(app: Hono): void {
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
}

View File

@@ -0,0 +1,10 @@
import { betterAuth } from "better-auth"
import { pool } from "../db.ts"
export const auth = betterAuth({
database: pool,
emailAndPassword: {
enabled: true,
},
})

View File

@@ -0,0 +1,54 @@
import type { Context, Next } from "hono"
import { auth } from "./index.ts"
type SessionUser = typeof auth.$Infer.Session.user
type Session = typeof auth.$Infer.Session.session
export interface SessionVariables {
user: SessionUser | null
session: Session | null
}
/**
* Middleware that attaches session and user to the context.
* Does not reject unauthenticated requests - use requireSession for that.
*/
export async function sessionMiddleware(c: Context, next: Next): Promise<void> {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (session) {
c.set("user", session.user)
c.set("session", session.session)
} else {
c.set("user", null)
c.set("session", null)
}
await next()
}
/**
* Middleware that requires a valid session. Returns 401 if not authenticated.
*/
export async function requireSession(c: Context, next: Next): Promise<Response | void> {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (!session) {
return c.json({ error: "Unauthorized" }, 401)
}
c.set("user", session.user)
c.set("session", session.session)
await next()
}
/**
* Get session from headers. Useful for WebSocket upgrade validation.
*/
export async function getSessionFromHeaders(
headers: Headers,
): Promise<{ user: SessionUser; session: Session } | null> {
const session = await auth.api.getSession({ headers })
return session
}

View File

@@ -0,0 +1,5 @@
import { Pool } from "pg"
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})

View File

@@ -0,0 +1,162 @@
import { describe, expect, mock, test } from "bun:test"
import { LocationService } from "../location/service.ts"
import { FeedEngineService } from "./service.ts"
describe("FeedEngineService", () => {
test("engineForUser creates engine on first call", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine = service.engineForUser("user-1")
expect(engine).toBeDefined()
})
test("engineForUser returns same engine for same user", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
const engine2 = service.engineForUser("user-1")
expect(engine1).toBe(engine2)
})
test("engineForUser returns different engines for different users", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
const engine2 = service.engineForUser("user-2")
expect(engine1).not.toBe(engine2)
})
test("engineForUser registers sources from all providers", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine = service.engineForUser("user-1")
const result = await engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("engineForUser works with empty providers array", async () => {
const service = new FeedEngineService([])
const engine = service.engineForUser("user-1")
const result = await engine.refresh()
expect(result.errors).toHaveLength(0)
expect(result.items).toHaveLength(0)
})
test("refresh returns feed result", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const result = await service.refresh("user-1")
expect(result).toHaveProperty("context")
expect(result).toHaveProperty("items")
expect(result).toHaveProperty("errors")
expect(result.context.time).toBeInstanceOf(Date)
})
test("refresh uses location from LocationService", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
// Create engine first, then update location
service.engineForUser("user-1")
locationService.updateUserLocation("user-1", location)
const result = await service.refresh("user-1")
expect(result.context.location).toEqual(location)
})
test("subscribe receives updates", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
service.subscribe("user-1", callback)
// Push location to trigger update
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
// Wait for async update propagation
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).toHaveBeenCalled()
})
test("subscribe returns unsubscribe function", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
const unsubscribe = service.subscribe("user-1", callback)
unsubscribe()
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
test("removeUser stops engine and removes it", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
service.subscribe("user-1", callback)
service.removeUser("user-1")
// Push location - should not trigger update since engine is stopped
locationService.feedSourceForUser("user-1")
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
test("removeUser allows new engine to be created", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
service.removeUser("user-1")
const engine2 = service.engineForUser("user-1")
expect(engine1).not.toBe(engine2)
})
})

View File

@@ -0,0 +1,75 @@
import { FeedEngine, type FeedResult, type FeedSource, type FeedSubscriber } from "@aris/core"
/**
* Provides a FeedSource instance for a user.
*/
export interface FeedSourceProvider {
feedSourceForUser(userId: string): FeedSource
}
/**
* Manages FeedEngine instances per user.
*
* Receives FeedSource instances from injected providers and wires them
* into per-user engines. Engines are auto-started on creation.
*/
export class FeedEngineService {
private engines = new Map<string, FeedEngine>()
constructor(private readonly providers: FeedSourceProvider[]) {}
/**
* Get or create a FeedEngine for a user.
* Automatically registers sources and starts the engine.
*/
engineForUser(userId: string): FeedEngine {
let engine = this.engines.get(userId)
if (!engine) {
engine = this.createEngine(userId)
this.engines.set(userId, engine)
}
return engine
}
/**
* Refresh a user's feed.
*/
async refresh(userId: string): Promise<FeedResult> {
const engine = this.engineForUser(userId)
return engine.refresh()
}
/**
* Subscribe to feed updates for a user.
* Returns unsubscribe function.
*/
subscribe(userId: string, callback: FeedSubscriber): () => void {
const engine = this.engineForUser(userId)
return engine.subscribe(callback)
}
/**
* Remove a user's FeedEngine.
* Stops the engine and cleans up resources.
*/
removeUser(userId: string): void {
const engine = this.engines.get(userId)
if (engine) {
engine.stop()
this.engines.delete(userId)
}
}
private createEngine(userId: string): FeedEngine {
const engine = new FeedEngine()
for (const provider of this.providers) {
const source = provider.feedSourceForUser(userId)
engine.register(source)
}
engine.start()
return engine
}
}

View File

@@ -0,0 +1,8 @@
export class UserNotFoundError extends Error {
constructor(
public readonly userId: string,
message?: string,
) {
super(message ? `${message}: user not found: ${userId}` : `User not found: ${userId}`)
}
}

View File

@@ -0,0 +1,33 @@
import { TRPCError } from "@trpc/server"
import { type } from "arktype"
import { UserNotFoundError } from "../lib/error.ts"
import type { TRPC } from "../trpc/router.ts"
import type { LocationService } from "./service.ts"
const locationInput = type({
lat: "number",
lng: "number",
accuracy: "number",
timestamp: "Date",
})
export function createLocationRouter(t: TRPC, { locationService }: { locationService: LocationService }) {
return t.router({
update: t.procedure.input(locationInput).mutation(({ input, ctx }) => {
try {
locationService.updateUserLocation(ctx.user.id, {
lat: input.lat,
lng: input.lng,
accuracy: input.accuracy,
timestamp: input.timestamp,
})
} catch (error) {
if (error instanceof UserNotFoundError) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message })
}
throw error
}
}),
})
}

View File

@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { UserNotFoundError } from "../lib/error.ts"
import { LocationService } from "./service.ts"
describe("LocationService", () => {
test("feedSourceForUser creates source on first call", () => {
const service = new LocationService()
const source = service.feedSourceForUser("user-1")
expect(source).toBeDefined()
expect(source.id).toBe("location")
})
test("feedSourceForUser returns same source for same user", () => {
const service = new LocationService()
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).toBe(source2)
})
test("feedSourceForUser returns different sources for different users", () => {
const service = new LocationService()
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-2")
expect(source1).not.toBe(source2)
})
test("updateUserLocation updates the source", () => {
const service = new LocationService()
const source = service.feedSourceForUser("user-1")
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
service.updateUserLocation("user-1", location)
expect(source.lastLocation).toEqual(location)
})
test("updateUserLocation throws if source does not exist", () => {
const service = new LocationService()
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
expect(() => service.updateUserLocation("user-1", location)).toThrow(UserNotFoundError)
})
test("lastUserLocation returns null for unknown user", () => {
const service = new LocationService()
expect(service.lastUserLocation("unknown")).toBeNull()
})
test("lastUserLocation returns last location", () => {
const service = new LocationService()
service.feedSourceForUser("user-1")
const location1 = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
const location2 = {
lat: 52.0,
lng: -0.2,
accuracy: 5,
timestamp: new Date(),
}
service.updateUserLocation("user-1", location1)
service.updateUserLocation("user-1", location2)
expect(service.lastUserLocation("user-1")).toEqual(location2)
})
test("removeUser removes the source", () => {
const service = new LocationService()
service.feedSourceForUser("user-1")
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
service.updateUserLocation("user-1", location)
service.removeUser("user-1")
expect(service.lastUserLocation("user-1")).toBeNull()
})
test("removeUser allows new source to be created", () => {
const service = new LocationService()
const source1 = service.feedSourceForUser("user-1")
service.removeUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).not.toBe(source2)
})
})

View File

@@ -0,0 +1,57 @@
import { LocationSource, type Location } from "@aris/source-location"
import type { FeedSourceProvider } from "../feed/service.ts"
import { UserNotFoundError } from "../lib/error.ts"
/**
* Manages LocationSource instances per user.
*/
export class LocationService implements FeedSourceProvider {
private sources = new Map<string, LocationSource>()
/**
* Get or create a LocationSource for a user.
* @param userId - The user's unique identifier
* @returns The user's LocationSource instance
*/
feedSourceForUser(userId: string): LocationSource {
let source = this.sources.get(userId)
if (!source) {
source = new LocationSource()
this.sources.set(userId, source)
}
return source
}
/**
* Update location for a user.
* @param userId - The user's unique identifier
* @param location - The new location data
* @throws {UserNotFoundError} If no source exists for the user
*/
updateUserLocation(userId: string, location: Location): void {
const source = this.sources.get(userId)
if (!source) {
throw new UserNotFoundError(userId)
}
source.pushLocation(location)
}
/**
* Get last known location for a user.
* @param userId - The user's unique identifier
* @returns The last location, or null if none exists
*/
lastUserLocation(userId: string): Location | null {
return this.sources.get(userId)?.lastLocation ?? null
}
/**
* Remove a user's LocationSource.
* @param userId - The user's unique identifier
*/
removeUser(userId: string): void {
this.sources.delete(userId)
}
}

View File

@@ -0,0 +1,36 @@
import { trpcServer } from "@hono/trpc-server"
import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts"
import { LocationService } from "./location/service.ts"
import { createContext } from "./trpc/context.ts"
import { createTRPCRouter } from "./trpc/router.ts"
function main() {
const locationService = new LocationService()
const trpcRouter = createTRPCRouter({ locationService })
const app = new Hono()
app.get("/health", (c) => c.json({ status: "ok" }))
registerAuthHandlers(app)
app.use(
"/trpc/*",
trpcServer({
router: trpcRouter,
createContext,
}),
)
return app
}
const app = main()
export default {
port: 3000,
fetch: app.fetch,
}

View File

@@ -0,0 +1,206 @@
import type { Context } from "@aris/core"
import type { ITflApi, StationLocation, TflLineId, TflLineStatus } from "@aris/source-tfl"
import { describe, expect, test } from "bun:test"
import { UserNotFoundError } from "../lib/error.ts"
import { TflService } from "./service.ts"
class StubTflApi implements ITflApi {
private statuses: TflLineStatus[]
constructor(statuses: TflLineStatus[] = []) {
this.statuses = statuses
}
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
if (lines) {
return this.statuses.filter((s) => lines.includes(s.lineId))
}
return this.statuses
}
async fetchStations(): Promise<StationLocation[]> {
return [
{
id: "940GZZLUKSX",
name: "King's Cross",
lat: 51.5308,
lng: -0.1238,
lines: ["northern", "victoria"],
},
]
}
}
function createContext(): Context {
return { time: new Date("2026-01-15T12:00:00Z") }
}
const sampleStatuses: TflLineStatus[] = [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Minor delays on the Northern line",
},
{
lineId: "victoria",
lineName: "Victoria",
severity: "major-delays",
description: "Severe delays on the Victoria line",
},
{
lineId: "central",
lineName: "Central",
severity: "closure",
description: "Central line suspended",
},
]
describe("TflService", () => {
test("feedSourceForUser creates source on first call", () => {
const service = new TflService(new StubTflApi())
const source = service.feedSourceForUser("user-1")
expect(source).toBeDefined()
expect(source.id).toBe("tfl")
})
test("feedSourceForUser returns same source for same user", () => {
const service = new TflService(new StubTflApi())
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).toBe(source2)
})
test("feedSourceForUser returns different sources for different users", () => {
const service = new TflService(new StubTflApi())
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-2")
expect(source1).not.toBe(source2)
})
test("updateLinesOfInterest mutates the existing source in place", () => {
const service = new TflService(new StubTflApi())
const original = service.feedSourceForUser("user-1")
service.updateLinesOfInterest("user-1", ["northern", "victoria"])
const after = service.feedSourceForUser("user-1")
expect(after).toBe(original)
})
test("updateLinesOfInterest throws if source does not exist", () => {
const service = new TflService(new StubTflApi())
expect(() => service.updateLinesOfInterest("user-1", ["northern"])).toThrow(UserNotFoundError)
})
test("removeUser removes the source", () => {
const service = new TflService(new StubTflApi())
const source1 = service.feedSourceForUser("user-1")
service.removeUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).not.toBe(source2)
})
test("removeUser clears line configuration", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
service.feedSourceForUser("user-1")
service.updateLinesOfInterest("user-1", ["northern"])
service.removeUser("user-1")
const items = await service.feedSourceForUser("user-1").fetchItems(createContext())
expect(items.length).toBe(3)
})
test("shares single api instance across users", () => {
const api = new StubTflApi()
const service = new TflService(api)
service.feedSourceForUser("user-1")
service.feedSourceForUser("user-2")
expect(service.feedSourceForUser("user-1").id).toBe("tfl")
expect(service.feedSourceForUser("user-2").id).toBe("tfl")
})
describe("returned source fetches items", () => {
test("source returns feed items from api", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
const source = service.feedSourceForUser("user-1")
const items = await source.fetchItems(createContext())
expect(items.length).toBe(3)
for (const item of items) {
expect(item.type).toBe("tfl-alert")
expect(item.id).toMatch(/^tfl-alert-/)
expect(typeof item.priority).toBe("number")
expect(item.timestamp).toBeInstanceOf(Date)
}
})
test("source returns items sorted by priority descending", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
const source = service.feedSourceForUser("user-1")
const items = await source.fetchItems(createContext())
for (let i = 1; i < items.length; i++) {
expect(items[i - 1]!.priority).toBeGreaterThanOrEqual(items[i]!.priority)
}
})
test("source returns empty array when no disruptions", async () => {
const api = new StubTflApi([])
const service = new TflService(api)
const source = service.feedSourceForUser("user-1")
const items = await source.fetchItems(createContext())
expect(items).toEqual([])
})
test("updateLinesOfInterest filters items to configured lines", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
const before = await service.feedSourceForUser("user-1").fetchItems(createContext())
expect(before.length).toBe(3)
service.updateLinesOfInterest("user-1", ["northern"])
const after = await service.feedSourceForUser("user-1").fetchItems(createContext())
expect(after.length).toBe(1)
expect(after[0]!.data.line).toBe("northern")
})
test("different users get independent line configs", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
service.feedSourceForUser("user-1")
service.feedSourceForUser("user-2")
service.updateLinesOfInterest("user-1", ["northern"])
service.updateLinesOfInterest("user-2", ["central"])
const items1 = await service.feedSourceForUser("user-1").fetchItems(createContext())
const items2 = await service.feedSourceForUser("user-2").fetchItems(createContext())
expect(items1.length).toBe(1)
expect(items1[0]!.data.line).toBe("northern")
expect(items2.length).toBe(1)
expect(items2[0]!.data.line).toBe("central")
})
})
})

View File

@@ -0,0 +1,40 @@
import { TflSource, type ITflApi, type TflLineId } from "@aris/source-tfl"
import type { FeedSourceProvider } from "../feed/service.ts"
import { UserNotFoundError } from "../lib/error.ts"
/**
* Manages per-user TflSource instances with individual line configuration.
*/
export class TflService implements FeedSourceProvider {
private sources = new Map<string, TflSource>()
constructor(private readonly api: ITflApi) {}
feedSourceForUser(userId: string): TflSource {
let source = this.sources.get(userId)
if (!source) {
source = new TflSource({ client: this.api })
this.sources.set(userId, source)
}
return source
}
/**
* Update monitored lines for a user. Mutates the existing TflSource
* so that references held by FeedEngine remain valid.
* @throws {UserNotFoundError} If no source exists for the user
*/
updateLinesOfInterest(userId: string, lines: TflLineId[]): void {
const source = this.sources.get(userId)
if (!source) {
throw new UserNotFoundError(userId)
}
source.setLinesOfInterest(lines)
}
removeUser(userId: string): void {
this.sources.delete(userId)
}
}

View File

@@ -0,0 +1,14 @@
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"
import { auth } from "../auth/index.ts"
export async function createContext(opts: FetchCreateContextFnOptions) {
const session = await auth.api.getSession({ headers: opts.req.headers })
return {
user: session?.user ?? null,
session: session?.session ?? null,
}
}
export type Context = Awaited<ReturnType<typeof createContext>>

View File

@@ -0,0 +1,47 @@
import { initTRPC, TRPCError } from "@trpc/server"
import { createLocationRouter } from "../location/router.ts"
import type { LocationService } from "../location/service.ts"
import type { Context } from "./context.ts"
interface AuthedContext {
user: NonNullable<Context["user"]>
session: NonNullable<Context["session"]>
}
function createTRPC() {
const t = initTRPC.context<Context>().create()
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user || !ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED" })
}
return next({
ctx: {
user: ctx.user,
session: ctx.session,
},
})
})
return {
router: t.router,
procedure: t.procedure.use(isAuthed),
}
}
export type TRPC = ReturnType<typeof createTRPC>
export interface TRPCRouterDeps {
locationService: LocationService
}
export function createTRPCRouter({ locationService }: TRPCRouterDeps) {
const t = createTRPC()
return t.router({
location: createLocationRouter(t, { locationService }),
})
}
export type TRPCRouter = ReturnType<typeof createTRPCRouter>

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}

128
bun.lock
View File

@@ -13,17 +13,28 @@
"typescript": "^5",
},
},
"packages/aris-core": {
"name": "@aris/core",
"version": "0.0.0",
},
"packages/aris-data-source-tfl": {
"name": "@aris/data-source-tfl",
"apps/aris-backend": {
"name": "@aris/backend",
"version": "0.0.0",
"dependencies": {
"@aris/core": "workspace:*",
"arktype": "^2.1.0",
"@aris/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@hono/trpc-server": "^0.3",
"@trpc/server": "^11",
"arktype": "^2.1.29",
"better-auth": "^1",
"hono": "^4",
"pg": "^8",
},
"devDependencies": {
"@types/pg": "^8",
},
},
"packages/aris-core": {
"name": "@aris/core",
"version": "0.0.0",
},
"packages/aris-data-source-weatherkit": {
"name": "@aris/data-source-weatherkit",
@@ -33,18 +44,63 @@
"arktype": "^2.1.0",
},
},
"packages/aris-source-location": {
"name": "@aris/source-location",
"version": "0.0.0",
"dependencies": {
"@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",
"dependencies": {
"@aris/core": "workspace:*",
"@aris/source-location": "workspace:*",
"arktype": "^2.1.0",
},
},
},
"packages": {
"@aris/backend": ["@aris/backend@workspace:apps/aris-backend"],
"@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=="],
"@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="],
"@better-auth/core": ["@better-auth/core@1.4.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-WSaEQDdUO6B1CzAmissN6j0lx9fM9lcslEYzlApB5UzFaBeAOHNUONTdglSyUs6/idiZBoRvt0t/qMXCgIU8ug=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.17", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.17" } }, "sha512-R1BC4e/bNjQbXu7lG6ubpgmsPj7IMqky5DvMlzAtnAJWJhh99pMh/n6w5gOHa0cqDZgEAuj75IPTxv+q3YiInA=="],
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@hono/trpc-server": ["@hono/trpc-server@0.3.4", "", { "peerDependencies": { "@trpc/server": "^10.10.0 || >11.0.0-rc", "hono": ">=4.*" } }, "sha512-xFOPjUPnII70FgicDzOJy1ufIoBTu8eF578zGiDOrYOrYN8CJe140s9buzuPkX+SwJRYK8LjEBHywqZtxdm8aA=="],
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A=="],
"@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg=="],
@@ -77,24 +133,78 @@
"@oxlint/win32-x64": ["@oxlint/win32-x64@1.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sbi25lfj74hH+6qQtb7s1wEvd1j8OQbTaH8v3xTcDjrwm579Cyh0HBv1YSZ2+gsnVwfVDiCTL1D0JsNqYXszVA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@trpc/server": ["@trpc/server@11.8.1", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
"arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="],
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
"better-auth": ["better-auth@1.4.17", "", { "dependencies": { "@better-auth/core": "1.4.17", "@better-auth/telemetry": "1.4.17", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew=="],
"better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"kysely": ["kysely@0.28.10", "", {}, "sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA=="],
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
"oxfmt": ["oxfmt@0.24.0", "", { "dependencies": { "tinypool": "2.0.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.24.0", "@oxfmt/darwin-x64": "0.24.0", "@oxfmt/linux-arm64-gnu": "0.24.0", "@oxfmt/linux-arm64-musl": "0.24.0", "@oxfmt/linux-x64-gnu": "0.24.0", "@oxfmt/linux-x64-musl": "0.24.0", "@oxfmt/win32-arm64": "0.24.0", "@oxfmt/win32-x64": "0.24.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw=="],
"oxlint": ["oxlint@1.39.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.39.0", "@oxlint/darwin-x64": "1.39.0", "@oxlint/linux-arm64-gnu": "1.39.0", "@oxlint/linux-arm64-musl": "1.39.0", "@oxlint/linux-x64-gnu": "1.39.0", "@oxlint/linux-x64-musl": "1.39.0", "@oxlint/win32-arm64": "1.39.0", "@oxlint/win32-x64": "1.39.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.10.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-wSiLr0wjG+KTU6c1LpVoQk7JZ7l8HCKlAkVDVTJKWmCGazsNxexxnOXl7dsar92mQcRnzko5g077ggP3RINSjA=="],
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-connection-string": ["pg-connection-string@2.10.1", "", {}, "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.11.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w=="],
"pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"tinypool": ["tinypool@2.0.0", "", {}, "sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
}
}

178
docs/backend-spec.md Normal file
View File

@@ -0,0 +1,178 @@
# ARIS Backend Specification
## Problem Statement
ARIS needs a backend service that manages per-user FeedEngine instances and delivers real-time feed updates to clients. The backend must handle authentication, maintain WebSocket connections for live updates, and accept context updates (like location) that trigger feed recalculations.
## Requirements
### Authentication
- Email/password authentication using BetterAuth
- PostgreSQL for session and user storage
- Session tokens validated via `Authorization: Bearer <token>` header
- Auth endpoints exposed via BetterAuth's built-in routes
### FeedEngine Management
- Each authenticated user gets their own FeedEngine instance
- Instances are cached in memory with a 30-minute TTL
- TTL resets on any activity (WebSocket message, location update)
- Default sources registered for each user: `LocationSource`, `WeatherSource`, `TflSource`
- Source configuration is hardcoded initially (customization deferred)
### WebSocket Connection
- Single endpoint: `GET /ws` (upgrades to WebSocket)
- Authentication via `Authorization: Bearer <token>` header on upgrade request
- Rejected before upgrade if token is invalid
- Multiple connections per user allowed (e.g., multiple devices)
- All connections for a user receive the same feed updates
- On connect: immediately send current feed state
### JSON-RPC Protocol
All WebSocket communication uses JSON-RPC 2.0.
**Client → Server (Requests):**
```json
{ "jsonrpc": "2.0", "method": "location.update", "params": { "lat": 51.5, "lng": -0.1, "accuracy": 10, "timestamp": "2025-01-01T12:00:00Z" }, "id": 1 }
{ "jsonrpc": "2.0", "method": "feed.refresh", "params": {}, "id": 2 }
```
**Server → Client (Responses):**
```json
{ "jsonrpc": "2.0", "result": { "ok": true }, "id": 1 }
```
**Server → Client (Notifications - no id):**
```json
{ "jsonrpc": "2.0", "method": "feed.update", "params": { "items": [...], "errors": [...] } }
```
### JSON-RPC Methods
| Method | Params | Description |
|--------|--------|-------------|
| `location.update` | `{ lat, lng, accuracy, timestamp }` | Push location update, triggers feed refresh |
| `feed.refresh` | `{}` | Force manual feed refresh |
### Server Notifications
| Method | Params | Description |
|--------|--------|-------------|
| `feed.update` | `{ context, items, errors }` | Feed state changed |
| `error` | `{ code, message, data? }` | Source or system error |
### Error Handling
- Source failures during refresh are reported via `error` notification
- Format: `{ "jsonrpc": "2.0", "method": "error", "params": { "code": -32000, "message": "...", "data": { "sourceId": "weather" } } }`
## Acceptance Criteria
1. **Auth Flow**
- [ ] User can sign up with email/password via `POST /api/auth/sign-up`
- [ ] User can sign in via `POST /api/auth/sign-in` and receive session token
- [ ] Invalid credentials return 401
2. **WebSocket Connection**
- [ ] `GET /ws` with valid `Authorization` header upgrades to WebSocket
- [ ] `GET /ws` without valid token returns 401 (no upgrade)
- [ ] On successful connect, client receives `feed.update` notification with current state
- [ ] Multiple connections from same user all receive updates
3. **FeedEngine Lifecycle**
- [ ] First connection for a user creates FeedEngine with default sources
- [ ] Subsequent connections reuse the same FeedEngine
- [ ] FeedEngine is destroyed after 30 minutes of inactivity
- [ ] Activity (any WebSocket message) resets the TTL
4. **JSON-RPC Methods**
- [ ] `location.update` updates LocationSource and triggers feed refresh
- [ ] `feed.refresh` triggers manual refresh
- [ ] Both return `{ "ok": true }` on success
- [ ] Invalid method returns JSON-RPC error
5. **Feed Updates**
- [ ] FeedEngine subscription pushes updates to all user's WebSocket connections
- [ ] Updates include `context`, `items`, and `errors`
## Implementation Approach
### Phase 1: Project Setup
1. Create `apps/aris-backend` with Hono
2. Configure TypeScript, add dependencies (hono, better-auth, postgres driver)
3. Set up database connection and BetterAuth
### Phase 2: Authentication
4. Configure BetterAuth with email/password provider
5. Mount BetterAuth routes at `/api/auth/*`
6. Create session validation helper for extracting user from token
### Phase 3: FeedEngine Manager
7. Create `FeedEngineManager` class:
- `getOrCreate(userId): FeedEngine` - returns cached or creates new
- `touch(userId)` - resets TTL
- `destroy(userId)` - manual cleanup
- Internal TTL cleanup loop
8. Factory function to create FeedEngine with default sources
### Phase 4: WebSocket Handler
9. Create WebSocket upgrade endpoint at `/ws`
10. Validate `Authorization` header before upgrade
11. On connect: register connection, send initial feed state
12. On disconnect: unregister connection
### Phase 5: JSON-RPC Handler
13. Create JSON-RPC message parser and dispatcher
14. Implement `location.update` method
15. Implement `feed.refresh` method
16. Wire FeedEngine subscription to broadcast `feed.update` to all user connections
### Phase 6: Connection Manager
17. Create `ConnectionManager` to track WebSocket connections per user
18. Broadcast helper to send to all connections for a user
### Phase 7: Integration & Testing
19. Integration test: auth → connect → location update → receive feed
20. Test multiple connections receive same updates
21. Test TTL cleanup
## Package Structure
```
apps/aris-backend/
├── package.json
├── src/
│ ├── index.ts # Entry point, Hono app
│ ├── auth.ts # BetterAuth configuration
│ ├── db.ts # Database connection
│ ├── ws/
│ │ ├── handler.ts # WebSocket upgrade & message handling
│ │ ├── jsonrpc.ts # JSON-RPC parser & types
│ │ └── methods.ts # Method implementations
│ ├── feed/
│ │ ├── manager.ts # FeedEngineManager (TTL cache)
│ │ ├── factory.ts # Creates FeedEngine with default sources
│ │ └── connections.ts # ConnectionManager (user → WebSocket[])
│ └── types.ts # Shared types
```
## Dependencies
```json
{
"dependencies": {
"hono": "^4",
"better-auth": "^1",
"postgres": "^3",
"@aris/core": "workspace:*",
"@aris/source-location": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@aris/data-source-tfl": "workspace:*"
}
}
```
## Open Questions (Deferred)
- User source configuration storage (database schema)
- Rate limiting on WebSocket methods
- Reconnection handling (client-side concern)
- Horizontal scaling (would need Redis for shared state)

View File

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

View File

@@ -2,8 +2,8 @@
"name": "@aris/core",
"version": "0.0.0",
"type": "module",
"main": "index.ts",
"types": "index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ."
}

View 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()
})
})
})

View 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 }
}

View 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
})
})
})

View File

@@ -0,0 +1,76 @@
import type { Context } from "./context"
import type { FeedItem } from "./feed"
/**
* Unified interface for sources that provide context and/or feed items.
*
* Sources form a dependency graph - a source declares which other sources
* it depends on, and the graph ensures dependencies are resolved before
* dependents run.
*
* A source may:
* - Provide context for other sources (implement fetchContext/onContextUpdate)
* - Produce feed items (implement fetchItems/onItemsUpdate)
* - Both
*
* @example
* ```ts
* // Location source - provides context only
* const locationSource: FeedSource = {
* id: "location",
* fetchContext: async () => {
* const pos = await getCurrentPosition()
* return { location: { lat: pos.coords.latitude, lng: pos.coords.longitude } }
* },
* }
*
* // Weather source - depends on location, provides both context and items
* const weatherSource: FeedSource<WeatherFeedItem> = {
* id: "weather",
* dependencies: ["location"],
* fetchContext: async (ctx) => {
* const weather = await fetchWeather(ctx.location)
* return { weather }
* },
* fetchItems: async (ctx) => {
* return createWeatherFeedItems(ctx.weather)
* },
* }
* ```
*/
export interface FeedSource<TItem extends FeedItem = FeedItem> {
/** Unique identifier for this source */
readonly id: string
/** IDs of sources this source depends on */
readonly dependencies?: readonly string[]
/**
* Subscribe to reactive context updates.
* Called when the source can push context changes proactively.
* Returns cleanup function.
*/
onContextUpdate?(
callback: (update: Partial<Context>) => void,
getContext: () => Context,
): () => void
/**
* Fetch context on-demand.
* Called during manual refresh or initial load.
*/
fetchContext?(context: Context): Promise<Partial<Context>>
/**
* Subscribe to reactive feed item updates.
* Called when the source can push item changes proactively.
* Returns cleanup function.
*/
onItemsUpdate?(callback: (items: TItem[]) => void, getContext: () => Context): () => void
/**
* Fetch feed items on-demand.
* Called during manual refresh or when dependencies update.
*/
fetchItems?(context: Context): Promise<TItem[]>
}

View File

@@ -5,20 +5,38 @@ export { contextKey, contextValue } from "./context"
// Feed
export type { FeedItem } from "./feed"
// Data Source
// 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"

View File

@@ -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)
})
})

View File

@@ -1,115 +0,0 @@
import type { Context, DataSource } from "@aris/core"
import type {
StationLocation,
TflAlertData,
TflAlertFeedItem,
TflAlertSeverity,
TflDataSourceConfig,
TflDataSourceOptions,
TflLineId,
} from "./types.ts"
import { TflApi, type ITflApi } from "./tfl-api.ts"
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
closure: 100,
"major-delays": 80,
"minor-delays": 60,
}
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371 // Earth's radius in km
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLng = ((lng2 - lng1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
function findClosestStationDistance(
lineId: TflLineId,
stations: StationLocation[],
userLat: number,
userLng: number,
): number | null {
const lineStations = stations.filter((s) => s.lines.includes(lineId))
if (lineStations.length === 0) return null
let minDistance = Infinity
for (const station of lineStations) {
const distance = haversineDistance(userLat, userLng, station.lat, station.lng)
if (distance < minDistance) {
minDistance = distance
}
}
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
}
}

View File

@@ -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"

View File

@@ -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")
}
}
})
})

View File

@@ -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[]
}

View File

@@ -0,0 +1,112 @@
# @aris/source-location
A FeedSource that provides location context to the ARIS feed graph.
## Overview
This source accepts external location pushes and does not query location itself. It provides location context to downstream sources (e.g., weather, transit) but does not produce feed items.
## Installation
```bash
bun add @aris/source-location
```
## Usage
```ts
import { LocationSource, LocationKey, type Location } from "@aris/source-location"
import { contextValue } from "@aris/core"
// Create source with default history size (1)
const locationSource = new LocationSource()
// Or keep last 10 locations
const locationSource = new LocationSource({ historySize: 10 })
// Push location from external provider (GPS, network, etc.)
locationSource.pushLocation({
lat: 37.7749,
lng: -122.4194,
accuracy: 10,
timestamp: new Date(),
})
// Access current location
locationSource.lastLocation // { lat, lng, accuracy, timestamp } | null
// Access location history (oldest first)
locationSource.locationHistory // readonly Location[]
```
### With FeedController
```ts
import { FeedController } from "@aris/core"
import { LocationSource } from "@aris/source-location"
const locationSource = new LocationSource()
const controller = new FeedController({
sources: [locationSource, weatherSource, transitSource],
})
// Push location updates - downstream sources will re-fetch
locationSource.pushLocation({
lat: 37.7749,
lng: -122.4194,
accuracy: 10,
timestamp: new Date(),
})
```
### Reading Location in Downstream Sources
```ts
import { contextValue, type FeedSource } from "@aris/core"
import { LocationKey } from "@aris/source-location"
const weatherSource: FeedSource = {
id: "weather",
dependencies: ["location"],
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
const weather = await fetchWeather(location.lat, location.lng)
return { [WeatherKey]: weather }
},
}
```
## API
### `LocationSource`
| Member | Type | Description |
| ------------------------ | --------------------- | ------------------------------------- |
| `id` | `"location"` | Source identifier |
| `constructor(options?)` | | Create with optional `historySize` |
| `pushLocation(location)` | `void` | Push new location, notifies listeners |
| `lastLocation` | `Location \| null` | Most recent location |
| `locationHistory` | `readonly Location[]` | All retained locations, oldest first |
### `Location`
```ts
interface Location {
lat: number
lng: number
accuracy: number // meters
timestamp: Date
}
```
### `LocationKey`
Typed context key for accessing location in downstream sources:
```ts
const location = contextValue(context, LocationKey)
```

View File

@@ -0,0 +1,13 @@
{
"name": "@aris/source-location",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test src/"
},
"dependencies": {
"@aris/core": "workspace:*"
}
}

View File

@@ -0,0 +1,6 @@
export {
LocationSource,
LocationKey,
type Location,
type LocationSourceOptions,
} from "./location-source.ts"

View File

@@ -0,0 +1,150 @@
import { describe, expect, mock, test } from "bun:test"
import { LocationKey, LocationSource, type Location } from "./location-source.ts"
function createLocation(overrides: Partial<Location> = {}): Location {
return {
lat: 37.7749,
lng: -122.4194,
accuracy: 10,
timestamp: new Date(),
...overrides,
}
}
describe("LocationSource", () => {
describe("FeedSource interface", () => {
test("has correct id", () => {
const source = new LocationSource()
expect(source.id).toBe("location")
})
test("fetchItems always returns empty array", async () => {
const source = new LocationSource()
source.pushLocation(createLocation())
const items = await source.fetchItems()
expect(items).toEqual([])
})
test("fetchContext returns empty when no location", async () => {
const source = new LocationSource()
const context = await source.fetchContext()
expect(context).toEqual({})
})
test("fetchContext returns location when available", async () => {
const source = new LocationSource()
const location = createLocation()
source.pushLocation(location)
const context = await source.fetchContext()
expect(context).toEqual({ [LocationKey]: location })
})
})
describe("pushLocation", () => {
test("updates lastLocation", () => {
const source = new LocationSource()
expect(source.lastLocation).toBeNull()
const location = createLocation()
source.pushLocation(location)
expect(source.lastLocation).toEqual(location)
})
test("notifies listeners", () => {
const source = new LocationSource()
const listener = mock()
source.onContextUpdate(listener)
const location = createLocation()
source.pushLocation(location)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith({ [LocationKey]: location })
})
})
describe("history", () => {
test("default historySize is 1", () => {
const source = new LocationSource()
source.pushLocation(createLocation({ lat: 1 }))
source.pushLocation(createLocation({ lat: 2 }))
expect(source.locationHistory).toHaveLength(1)
expect(source.lastLocation?.lat).toBe(2)
})
test("respects configured historySize", () => {
const source = new LocationSource({ historySize: 3 })
const loc1 = createLocation({ lat: 1 })
const loc2 = createLocation({ lat: 2 })
const loc3 = createLocation({ lat: 3 })
source.pushLocation(loc1)
source.pushLocation(loc2)
source.pushLocation(loc3)
expect(source.locationHistory).toEqual([loc1, loc2, loc3])
})
test("evicts oldest when exceeding historySize", () => {
const source = new LocationSource({ historySize: 2 })
const loc1 = createLocation({ lat: 1 })
const loc2 = createLocation({ lat: 2 })
const loc3 = createLocation({ lat: 3 })
source.pushLocation(loc1)
source.pushLocation(loc2)
source.pushLocation(loc3)
expect(source.locationHistory).toEqual([loc2, loc3])
})
test("locationHistory is readonly", () => {
const source = new LocationSource({ historySize: 3 })
source.pushLocation(createLocation())
const history = source.locationHistory
expect(Array.isArray(history)).toBe(true)
})
})
describe("onContextUpdate", () => {
test("returns cleanup function", () => {
const source = new LocationSource()
const listener = mock()
const cleanup = source.onContextUpdate(listener)
source.pushLocation(createLocation({ lat: 1 }))
expect(listener).toHaveBeenCalledTimes(1)
cleanup()
source.pushLocation(createLocation({ lat: 2 }))
expect(listener).toHaveBeenCalledTimes(1)
})
test("supports multiple listeners", () => {
const source = new LocationSource()
const listener1 = mock()
const listener2 = mock()
source.onContextUpdate(listener1)
source.onContextUpdate(listener2)
source.pushLocation(createLocation())
expect(listener1).toHaveBeenCalledTimes(1)
expect(listener2).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,86 @@
import type { Context, FeedSource } from "@aris/core"
import { contextKey, type ContextKey } from "@aris/core"
/**
* Geographic coordinates with accuracy and timestamp.
*/
export interface Location {
lat: number
lng: number
/** Accuracy in meters */
accuracy: number
timestamp: Date
}
export interface LocationSourceOptions {
/** Number of locations to retain in history. Defaults to 1. */
historySize?: number
}
export const LocationKey: ContextKey<Location> = contextKey("location")
/**
* A FeedSource that provides location context.
*
* This source accepts external location pushes and does not query location itself.
* Use `pushLocation` to update the location from an external provider (e.g., GPS, network).
*
* Does not produce feed items - always returns empty array from `fetchItems`.
*/
export class LocationSource implements FeedSource {
readonly id = "location"
private readonly historySize: number
private locations: Location[] = []
private listeners = new Set<(update: Partial<Context>) => void>()
constructor(options: LocationSourceOptions = {}) {
this.historySize = options.historySize ?? 1
}
/**
* Push a new location update. Notifies all context listeners.
*/
pushLocation(location: Location): void {
this.locations.push(location)
if (this.locations.length > this.historySize) {
this.locations.shift()
}
this.listeners.forEach((listener) => {
listener({ [LocationKey]: location })
})
}
/**
* Most recent location, or null if none pushed.
*/
get lastLocation(): Location | null {
return this.locations[this.locations.length - 1] ?? null
}
/**
* Location history, oldest first. Length limited by `historySize`.
*/
get locationHistory(): readonly Location[] {
return this.locations
}
onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
this.listeners.add(callback)
return () => {
this.listeners.delete(callback)
}
}
async fetchContext(): Promise<Partial<Context>> {
if (this.lastLocation) {
return { [LocationKey]: this.lastLocation }
}
return {}
}
async fetchItems(): Promise<[]> {
return []
}
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,12 @@
export { TflSource } from "./tfl-source.ts"
export { TflApi } from "./tfl-api.ts"
export type { TflLineId } from "./tfl-api.ts"
export type {
ITflApi,
StationLocation,
TflAlertData,
TflAlertFeedItem,
TflAlertSeverity,
TflLineStatus,
TflSourceOptions,
} from "./types.ts"

View File

@@ -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

View File

@@ -0,0 +1,286 @@
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("setLinesOfInterest", () => {
const lineFilteringApi: ITflApi = {
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
const all: TflLineStatus[] = [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Delays",
},
{ lineId: "central", lineName: "Central", severity: "closure", description: "Closed" },
]
return lines ? all.filter((s) => lines.includes(s.lineId)) : all
},
async fetchStations(): Promise<StationLocation[]> {
return []
},
}
test("changes which lines are fetched", async () => {
const source = new TflSource({ client: lineFilteringApi })
const before = await source.fetchItems(createContext())
expect(before.length).toBe(2)
source.setLinesOfInterest(["northern"])
const after = await source.fetchItems(createContext())
expect(after.length).toBe(1)
expect(after[0]!.data.line).toBe("northern")
})
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
const source = new TflSource({ client: lineFilteringApi, lines: ["northern"] })
const filtered = await source.fetchItems(createContext())
expect(filtered.length).toBe(1)
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
const all = await source.fetchItems(createContext())
expect(all.length).toBe(2)
})
})
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")
}
}
})
})

View File

@@ -0,0 +1,164 @@
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,
TflLineId,
TflSourceOptions,
} from "./types.ts"
import { TflApi } from "./tfl-api.ts"
const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
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> {
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
"bakerloo",
"central",
"circle",
"district",
"hammersmith-city",
"jubilee",
"metropolitan",
"northern",
"piccadilly",
"victoria",
"waterloo-city",
"lioness",
"mildmay",
"windrush",
"weaver",
"suffragette",
"liberty",
"elizabeth",
]
readonly id = "tfl"
readonly dependencies = ["location"]
private readonly client: ITflApi
private 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 ?? [...TflSource.DEFAULT_LINES_OF_INTEREST]
}
/**
* Update the set of monitored lines. Takes effect on the next fetchItems call.
*/
setLinesOfInterest(lines: TflLineId[]): void {
this.lines = 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 {
const R = 6371 // Earth's radius in km
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLng = ((lng2 - lng1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
function findClosestStationDistance(
lineId: TflLineId,
stations: StationLocation[],
userLat: number,
userLng: number,
): number | null {
const lineStations = stations.filter((s) => s.lines.includes(lineId))
if (lineStations.length === 0) return null
let minDistance = Infinity
for (const station of lineStations) {
const distance = haversineDistance(userLat, userLng, station.lat, station.lng)
if (distance < minDistance) {
minDistance = distance
}
}
return minDistance
}

View 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
}

View File

@@ -0,0 +1,101 @@
# @aris/source-weatherkit
Weather feed source using Apple WeatherKit API.
## Usage
### Basic Setup
```ts
import { WeatherSource, Units } from "@aris/source-weatherkit"
const weatherSource = new WeatherSource({
credentials: {
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
keyId: process.env.WEATHERKIT_KEY_ID!,
teamId: process.env.WEATHERKIT_TEAM_ID!,
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
},
units: Units.metric,
})
```
### With Feed Source Graph
```ts
import { LocationSource } from "@aris/source-location"
import { WeatherSource } from "@aris/source-weatherkit"
const locationSource = new LocationSource()
const weatherSource = new WeatherSource({ credentials })
// Weather depends on location - graph handles ordering
const sources = [locationSource, weatherSource]
```
### Reading Weather Context
Downstream sources can access weather data:
```ts
import { contextValue } from "@aris/core"
import { WeatherKey } from "@aris/source-weatherkit"
async function fetchContext(context: Context) {
const weather = contextValue(context, WeatherKey)
if (weather?.condition === "Rain") {
// Suggest umbrella, indoor activities, etc.
}
if (weather && weather.uvIndex > 7) {
// Suggest sunscreen
}
}
```
## Exports
| Export | Description |
| --------------- | --------------------------------------- |
| `WeatherSource` | FeedSource implementation |
| `WeatherKey` | Context key for simplified weather data |
| `Weather` | Type for weather context |
| `Units` | `metric` or `imperial` |
## Options
| Option | Default | Description |
| ------------- | -------- | -------------------------- |
| `credentials` | - | WeatherKit API credentials |
| `client` | - | Custom WeatherKit client |
| `hourlyLimit` | `12` | Max hourly forecasts |
| `dailyLimit` | `7` | Max daily forecasts |
| `units` | `metric` | Temperature/speed units |
## Context
Provides simplified weather context for downstream sources:
```ts
interface Weather {
temperature: number
temperatureApparent: number
condition: ConditionCode
humidity: number
uvIndex: number
windSpeed: number
daylight: boolean
}
```
## Feed Items
Produces feed items:
- `weather-current` - Current conditions
- `weather-hourly` - Hourly forecasts (up to `hourlyLimit`)
- `weather-daily` - Daily forecasts (up to `dailyLimit`)
- `weather-alert` - Weather alerts when present
Priority is adjusted based on weather severity (storms, extreme temperatures).

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
{
"name": "@aris/source-weatherkit",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ."
},
"dependencies": {
"@aris/core": "workspace:*",
"@aris/source-location": "workspace:*",
"arktype": "^2.1.0"
}
}

View File

@@ -0,0 +1,97 @@
import type { FeedItem } from "@aris/core"
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
export const WeatherFeedItemType = {
current: "weather-current",
hourly: "weather-hourly",
daily: "weather-daily",
alert: "weather-alert",
} as const
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
export type CurrentWeatherData = {
conditionCode: ConditionCode
daylight: boolean
humidity: number
precipitationIntensity: number
pressure: number
pressureTrend: "rising" | "falling" | "steady"
temperature: number
temperatureApparent: number
uvIndex: number
visibility: number
windDirection: number
windGust: number
windSpeed: number
}
export interface CurrentWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.current,
CurrentWeatherData
> {}
export type HourlyWeatherData = {
forecastTime: Date
conditionCode: ConditionCode
daylight: boolean
humidity: number
precipitationAmount: number
precipitationChance: number
precipitationType: PrecipitationType
temperature: number
temperatureApparent: number
uvIndex: number
windDirection: number
windGust: number
windSpeed: number
}
export interface HourlyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.hourly,
HourlyWeatherData
> {}
export type DailyWeatherData = {
forecastDate: Date
conditionCode: ConditionCode
maxUvIndex: number
precipitationAmount: number
precipitationChance: number
precipitationType: PrecipitationType
snowfallAmount: number
sunrise: Date
sunset: Date
temperatureMax: number
temperatureMin: number
}
export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.daily,
DailyWeatherData
> {}
export type WeatherAlertData = {
alertId: string
areaName: string
certainty: Certainty
description: string
detailsUrl: string
effectiveTime: Date
expireTime: Date
severity: Severity
source: string
urgency: Urgency
}
export interface WeatherAlertFeedItem extends FeedItem<
typeof WeatherFeedItemType.alert,
WeatherAlertData
> {}
export type WeatherFeedItem =
| CurrentWeatherFeedItem
| HourlyWeatherFeedItem
| DailyWeatherFeedItem
| WeatherAlertFeedItem

View File

@@ -0,0 +1,39 @@
export { WeatherKey, type Weather } from "./weather-context"
export {
WeatherSource,
Units,
type Units as UnitsType,
type WeatherSourceOptions,
} from "./weather-source"
export {
WeatherFeedItemType,
type WeatherFeedItemType as WeatherFeedItemTypeType,
type WeatherFeedItem,
type CurrentWeatherFeedItem,
type CurrentWeatherData,
type HourlyWeatherFeedItem,
type HourlyWeatherData,
type DailyWeatherFeedItem,
type DailyWeatherData,
type WeatherAlertFeedItem,
type WeatherAlertData,
} from "./feed-items"
export {
ConditionCode,
Severity,
Urgency,
Certainty,
PrecipitationType,
DefaultWeatherKitClient,
type ConditionCode as ConditionCodeType,
type Severity as SeverityType,
type Urgency as UrgencyType,
type Certainty as CertaintyType,
type PrecipitationType as PrecipitationTypeType,
type WeatherKitClient,
type WeatherKitCredentials,
type WeatherKitQueryOptions,
type WeatherKitResponse,
} from "./weatherkit"

View File

@@ -0,0 +1,27 @@
import type { ContextKey } from "@aris/core"
import { contextKey } from "@aris/core"
import type { ConditionCode } from "./weatherkit"
/**
* Simplified weather context for downstream sources.
*/
export interface Weather {
/** Current temperature */
temperature: number
/** Feels-like temperature */
temperatureApparent: number
/** Weather condition */
condition: ConditionCode
/** Relative humidity (0-1) */
humidity: number
/** UV index */
uvIndex: number
/** Wind speed */
windSpeed: number
/** Is it currently daytime */
daylight: boolean
}
export const WeatherKey: ContextKey<Weather> = contextKey("weather")

View File

@@ -0,0 +1,182 @@
import { contextValue, type Context } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType } from "./feed-items"
import { WeatherKey } from "./weather-context"
import { WeatherSource, Units } from "./weather-source"
const mockCredentials = {
privateKey: "mock",
keyId: "mock",
teamId: "mock",
serviceId: "mock",
}
function createMockClient(response: WeatherKitResponse): WeatherKitClient {
return {
fetch: async () => response,
}
}
function createMockContext(location?: { lat: number; lng: number }): Context {
const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") }
if (location) {
ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
}
return ctx
}
describe("WeatherSource", () => {
describe("properties", () => {
test("has correct id", () => {
const source = new WeatherSource({ credentials: mockCredentials })
expect(source.id).toBe("weather")
})
test("depends on location", () => {
const source = new WeatherSource({ credentials: mockCredentials })
expect(source.dependencies).toEqual(["location"])
})
test("throws error if neither client nor credentials provided", () => {
expect(() => new WeatherSource({} as never)).toThrow(
"Either client or credentials must be provided",
)
})
})
describe("fetchContext", () => {
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
test("returns empty when no location", async () => {
const source = new WeatherSource({ client: mockClient })
const result = await source.fetchContext(createMockContext())
expect(result).toEqual({})
})
test("returns simplified weather context", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const result = await source.fetchContext(context)
const weather = contextValue(result, WeatherKey)
expect(weather).toBeDefined()
expect(typeof weather!.temperature).toBe("number")
expect(typeof weather!.temperatureApparent).toBe("number")
expect(typeof weather!.condition).toBe("string")
expect(typeof weather!.humidity).toBe("number")
expect(typeof weather!.uvIndex).toBe("number")
expect(typeof weather!.windSpeed).toBe("number")
expect(typeof weather!.daylight).toBe("boolean")
})
test("converts temperature to imperial", async () => {
const source = new WeatherSource({ client: mockClient, units: Units.imperial })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const result = await source.fetchContext(context)
const weather = contextValue(result, WeatherKey)
// Fixture has temperature around 10°C, imperial should be around 50°F
expect(weather!.temperature).toBeGreaterThan(40)
})
})
describe("fetchItems", () => {
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
test("returns empty array when no location", async () => {
const source = new WeatherSource({ client: mockClient })
const items = await source.fetchItems(createMockContext())
expect(items).toEqual([])
})
test("returns feed items with all types", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
expect(items.length).toBeGreaterThan(0)
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
})
test("applies hourly and daily limits", async () => {
const source = new WeatherSource({
client: mockClient,
hourlyLimit: 3,
dailyLimit: 2,
})
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourlyItems.length).toBe(3)
expect(dailyItems.length).toBe(2)
})
test("sets timestamp from context.time", async () => {
const source = new WeatherSource({ client: mockClient })
const queryTime = new Date("2026-01-17T12:00:00Z")
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
context.time = queryTime
const items = await source.fetchItems(context)
for (const item of items) {
expect(item.timestamp).toEqual(queryTime)
}
})
test("assigns priority based on weather conditions", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
for (const item of items) {
expect(item.priority).toBeGreaterThanOrEqual(0)
expect(item.priority).toBeLessThanOrEqual(1)
}
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
expect(currentItem).toBeDefined()
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
})
test("generates unique IDs for each item", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const ids = items.map((i) => i.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
})
describe("no reactive methods", () => {
test("does not implement onContextUpdate", () => {
const source = new WeatherSource({ credentials: mockCredentials })
expect(source.onContextUpdate).toBeUndefined()
})
test("does not implement onItemsUpdate", () => {
const source = new WeatherSource({ credentials: mockCredentials })
expect(source.onItemsUpdate).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,363 @@
import type { Context, FeedSource } from "@aris/core"
import { contextValue } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
import { WeatherKey, type Weather } from "./weather-context"
import {
DefaultWeatherKitClient,
type ConditionCode,
type CurrentWeather,
type DailyForecast,
type HourlyForecast,
type Severity,
type WeatherAlert,
type WeatherKitClient,
type WeatherKitCredentials,
} from "./weatherkit"
export const Units = {
metric: "metric",
imperial: "imperial",
} as const
export type Units = (typeof Units)[keyof typeof Units]
export interface WeatherSourceOptions {
credentials?: WeatherKitCredentials
client?: WeatherKitClient
/** Number of hourly forecasts to include (default: 12) */
hourlyLimit?: number
/** Number of daily forecasts to include (default: 7) */
dailyLimit?: number
/** Units for temperature and measurements (default: metric) */
units?: Units
}
const DEFAULT_HOURLY_LIMIT = 12
const DEFAULT_DAILY_LIMIT = 7
const BASE_PRIORITY = {
current: 0.5,
hourly: 0.3,
daily: 0.2,
alert: 0.7,
} as const
const SEVERE_CONDITIONS = new Set<ConditionCode>([
"SevereThunderstorm",
"Hurricane",
"Tornado",
"TropicalStorm",
"Blizzard",
"FreezingRain",
"Hail",
"Frigid",
"Hot",
])
const MODERATE_CONDITIONS = new Set<ConditionCode>([
"Thunderstorm",
"IsolatedThunderstorms",
"ScatteredThunderstorms",
"HeavyRain",
"HeavySnow",
"FreezingDrizzle",
"BlowingSnow",
])
/**
* A FeedSource that provides weather context and feed items using Apple WeatherKit.
*
* Depends on location source for coordinates. Provides simplified weather context
* for downstream sources and produces weather feed items (current, hourly, daily, alerts).
*
* @example
* ```ts
* const weatherSource = new WeatherSource({
* credentials: {
* privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
* keyId: process.env.WEATHERKIT_KEY_ID!,
* teamId: process.env.WEATHERKIT_TEAM_ID!,
* serviceId: process.env.WEATHERKIT_SERVICE_ID!,
* },
* units: Units.metric,
* })
*
* // Access weather context in downstream sources
* const weather = contextValue(context, WeatherKey)
* if (weather?.condition === "Rain") {
* // suggest umbrella
* }
* ```
*/
export class WeatherSource implements FeedSource<WeatherFeedItem> {
readonly id = "weather"
readonly dependencies = ["location"]
private readonly client: WeatherKitClient
private readonly hourlyLimit: number
private readonly dailyLimit: number
private readonly units: Units
constructor(options: WeatherSourceOptions) {
if (!options.client && !options.credentials) {
throw new Error("Either client or credentials must be provided")
}
this.client = options.client ?? new DefaultWeatherKitClient(options.credentials!)
this.hourlyLimit = options.hourlyLimit ?? DEFAULT_HOURLY_LIMIT
this.dailyLimit = options.dailyLimit ?? DEFAULT_DAILY_LIMIT
this.units = options.units ?? Units.metric
}
async fetchContext(context: Context): Promise<Partial<Context>> {
const location = contextValue(context, LocationKey)
if (!location) {
return {}
}
const response = await this.client.fetch({
lat: location.lat,
lng: location.lng,
})
if (!response.currentWeather) {
return {}
}
const weather: Weather = {
temperature: convertTemperature(response.currentWeather.temperature, this.units),
temperatureApparent: convertTemperature(
response.currentWeather.temperatureApparent,
this.units,
),
condition: response.currentWeather.conditionCode,
humidity: response.currentWeather.humidity,
uvIndex: response.currentWeather.uvIndex,
windSpeed: convertSpeed(response.currentWeather.windSpeed, this.units),
daylight: response.currentWeather.daylight,
}
return { [WeatherKey]: weather }
}
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
const location = contextValue(context, LocationKey)
if (!location) {
return []
}
const timestamp = context.time
const response = await this.client.fetch({
lat: location.lat,
lng: location.lng,
})
const items: WeatherFeedItem[] = []
if (response.currentWeather) {
items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units))
}
if (response.forecastHourly?.hours) {
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
for (let i = 0; i < hours.length; i++) {
const hour = hours[i]
if (hour) {
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units))
}
}
}
if (response.forecastDaily?.days) {
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
for (let i = 0; i < days.length; i++) {
const day = days[i]
if (day) {
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units))
}
}
}
if (response.weatherAlerts?.alerts) {
for (const alert of response.weatherAlerts.alerts) {
items.push(createWeatherAlertFeedItem(alert, timestamp))
}
}
return items
}
}
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
if (SEVERE_CONDITIONS.has(conditionCode)) {
return Math.min(1, basePriority + 0.3)
}
if (MODERATE_CONDITIONS.has(conditionCode)) {
return Math.min(1, basePriority + 0.15)
}
return basePriority
}
function adjustPriorityForAlertSeverity(severity: Severity): number {
switch (severity) {
case "extreme":
return 1
case "severe":
return 0.9
case "moderate":
return 0.75
case "minor":
return BASE_PRIORITY.alert
}
}
function convertTemperature(celsius: number, units: Units): number {
if (units === Units.imperial) {
return (celsius * 9) / 5 + 32
}
return celsius
}
function convertSpeed(kmh: number, units: Units): number {
if (units === Units.imperial) {
return kmh * 0.621371
}
return kmh
}
function convertDistance(km: number, units: Units): number {
if (units === Units.imperial) {
return km * 0.621371
}
return km
}
function convertPrecipitation(mm: number, units: Units): number {
if (units === Units.imperial) {
return mm * 0.0393701
}
return mm
}
function convertPressure(mb: number, units: Units): number {
if (units === Units.imperial) {
return mb * 0.02953
}
return mb
}
function createCurrentWeatherFeedItem(
current: CurrentWeather,
timestamp: Date,
units: Units,
): WeatherFeedItem {
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
return {
id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.current,
priority,
timestamp,
data: {
conditionCode: current.conditionCode,
daylight: current.daylight,
humidity: current.humidity,
precipitationIntensity: convertPrecipitation(current.precipitationIntensity, units),
pressure: convertPressure(current.pressure, units),
pressureTrend: current.pressureTrend,
temperature: convertTemperature(current.temperature, units),
temperatureApparent: convertTemperature(current.temperatureApparent, units),
uvIndex: current.uvIndex,
visibility: convertDistance(current.visibility, units),
windDirection: current.windDirection,
windGust: convertSpeed(current.windGust, units),
windSpeed: convertSpeed(current.windSpeed, units),
},
}
}
function createHourlyWeatherFeedItem(
hourly: HourlyForecast,
index: number,
timestamp: Date,
units: Units,
): WeatherFeedItem {
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
return {
id: `weather-hourly-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.hourly,
priority,
timestamp,
data: {
forecastTime: new Date(hourly.forecastStart),
conditionCode: hourly.conditionCode,
daylight: hourly.daylight,
humidity: hourly.humidity,
precipitationAmount: convertPrecipitation(hourly.precipitationAmount, units),
precipitationChance: hourly.precipitationChance,
precipitationType: hourly.precipitationType,
temperature: convertTemperature(hourly.temperature, units),
temperatureApparent: convertTemperature(hourly.temperatureApparent, units),
uvIndex: hourly.uvIndex,
windDirection: hourly.windDirection,
windGust: convertSpeed(hourly.windGust, units),
windSpeed: convertSpeed(hourly.windSpeed, units),
},
}
}
function createDailyWeatherFeedItem(
daily: DailyForecast,
index: number,
timestamp: Date,
units: Units,
): WeatherFeedItem {
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
return {
id: `weather-daily-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.daily,
priority,
timestamp,
data: {
forecastDate: new Date(daily.forecastStart),
conditionCode: daily.conditionCode,
maxUvIndex: daily.maxUvIndex,
precipitationAmount: convertPrecipitation(daily.precipitationAmount, units),
precipitationChance: daily.precipitationChance,
precipitationType: daily.precipitationType,
snowfallAmount: convertPrecipitation(daily.snowfallAmount, units),
sunrise: new Date(daily.sunrise),
sunset: new Date(daily.sunset),
temperatureMax: convertTemperature(daily.temperatureMax, units),
temperatureMin: convertTemperature(daily.temperatureMin, units),
},
}
}
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
const priority = adjustPriorityForAlertSeverity(alert.severity)
return {
id: `weather-alert-${alert.id}`,
type: WeatherFeedItemType.alert,
priority,
timestamp,
data: {
alertId: alert.id,
areaName: alert.areaName,
certainty: alert.certainty,
description: alert.description,
detailsUrl: alert.detailsUrl,
effectiveTime: new Date(alert.effectiveTime),
expireTime: new Date(alert.expireTime),
severity: alert.severity,
source: alert.source,
urgency: alert.urgency,
},
}
}

View File

@@ -0,0 +1,367 @@
// WeatherKit REST API client and response types
// https://developer.apple.com/documentation/weatherkitrestapi
import { type } from "arktype"
export interface WeatherKitCredentials {
privateKey: string
keyId: string
teamId: string
serviceId: string
}
export interface WeatherKitQueryOptions {
lat: number
lng: number
language?: string
timezone?: string
}
export interface WeatherKitClient {
fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse>
}
export class DefaultWeatherKitClient implements WeatherKitClient {
private readonly credentials: WeatherKitCredentials
constructor(credentials: WeatherKitCredentials) {
this.credentials = credentials
}
async fetch(query: WeatherKitQueryOptions): Promise<WeatherKitResponse> {
const token = await generateJwt(this.credentials)
const dataSets = ["currentWeather", "forecastHourly", "forecastDaily", "weatherAlerts"].join(
",",
)
const url = new URL(
`${WEATHERKIT_API_BASE}/weather/${query.language ?? "en"}/${query.lat}/${query.lng}`,
)
url.searchParams.set("dataSets", dataSets)
if (query.timezone) {
url.searchParams.set("timezone", query.timezone)
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
const body = await response.text()
throw new Error(`WeatherKit API error: ${response.status} ${response.statusText}: ${body}`)
}
const json = await response.json()
const result = weatherKitResponseSchema(json)
if (result instanceof type.errors) {
throw new Error(`WeatherKit API response validation failed: ${result.summary}`)
}
return result
}
}
export const Severity = {
Minor: "minor",
Moderate: "moderate",
Severe: "severe",
Extreme: "extreme",
} as const
export type Severity = (typeof Severity)[keyof typeof Severity]
export const Urgency = {
Immediate: "immediate",
Expected: "expected",
Future: "future",
Past: "past",
Unknown: "unknown",
} as const
export type Urgency = (typeof Urgency)[keyof typeof Urgency]
export const Certainty = {
Observed: "observed",
Likely: "likely",
Possible: "possible",
Unlikely: "unlikely",
Unknown: "unknown",
} as const
export type Certainty = (typeof Certainty)[keyof typeof Certainty]
export const PrecipitationType = {
Clear: "clear",
Precipitation: "precipitation",
Rain: "rain",
Snow: "snow",
Sleet: "sleet",
Hail: "hail",
Mixed: "mixed",
} as const
export type PrecipitationType = (typeof PrecipitationType)[keyof typeof PrecipitationType]
export const ConditionCode = {
Clear: "Clear",
Cloudy: "Cloudy",
Dust: "Dust",
Fog: "Fog",
Haze: "Haze",
MostlyClear: "MostlyClear",
MostlyCloudy: "MostlyCloudy",
PartlyCloudy: "PartlyCloudy",
ScatteredThunderstorms: "ScatteredThunderstorms",
Smoke: "Smoke",
Breezy: "Breezy",
Windy: "Windy",
Drizzle: "Drizzle",
HeavyRain: "HeavyRain",
Rain: "Rain",
Showers: "Showers",
Flurries: "Flurries",
HeavySnow: "HeavySnow",
MixedRainAndSleet: "MixedRainAndSleet",
MixedRainAndSnow: "MixedRainAndSnow",
MixedRainfall: "MixedRainfall",
MixedSnowAndSleet: "MixedSnowAndSleet",
ScatteredShowers: "ScatteredShowers",
ScatteredSnowShowers: "ScatteredSnowShowers",
Sleet: "Sleet",
Snow: "Snow",
SnowShowers: "SnowShowers",
Blizzard: "Blizzard",
BlowingSnow: "BlowingSnow",
FreezingDrizzle: "FreezingDrizzle",
FreezingRain: "FreezingRain",
Frigid: "Frigid",
Hail: "Hail",
Hot: "Hot",
Hurricane: "Hurricane",
IsolatedThunderstorms: "IsolatedThunderstorms",
SevereThunderstorm: "SevereThunderstorm",
Thunderstorm: "Thunderstorm",
Tornado: "Tornado",
TropicalStorm: "TropicalStorm",
} as const
export type ConditionCode = (typeof ConditionCode)[keyof typeof ConditionCode]
const WEATHERKIT_API_BASE = "https://weatherkit.apple.com/api/v1"
const severitySchema = type.enumerated(
Severity.Minor,
Severity.Moderate,
Severity.Severe,
Severity.Extreme,
)
const urgencySchema = type.enumerated(
Urgency.Immediate,
Urgency.Expected,
Urgency.Future,
Urgency.Past,
Urgency.Unknown,
)
const certaintySchema = type.enumerated(
Certainty.Observed,
Certainty.Likely,
Certainty.Possible,
Certainty.Unlikely,
Certainty.Unknown,
)
const precipitationTypeSchema = type.enumerated(
PrecipitationType.Clear,
PrecipitationType.Precipitation,
PrecipitationType.Rain,
PrecipitationType.Snow,
PrecipitationType.Sleet,
PrecipitationType.Hail,
PrecipitationType.Mixed,
)
const conditionCodeSchema = type.enumerated(...Object.values(ConditionCode))
const pressureTrendSchema = type.enumerated("rising", "falling", "steady")
const currentWeatherSchema = type({
asOf: "string",
conditionCode: conditionCodeSchema,
daylight: "boolean",
humidity: "number",
precipitationIntensity: "number",
pressure: "number",
pressureTrend: pressureTrendSchema,
temperature: "number",
temperatureApparent: "number",
temperatureDewPoint: "number",
uvIndex: "number",
visibility: "number",
windDirection: "number",
windGust: "number",
windSpeed: "number",
})
export type CurrentWeather = typeof currentWeatherSchema.infer
const hourlyForecastSchema = type({
forecastStart: "string",
conditionCode: conditionCodeSchema,
daylight: "boolean",
humidity: "number",
precipitationAmount: "number",
precipitationChance: "number",
precipitationType: precipitationTypeSchema,
pressure: "number",
snowfallIntensity: "number",
temperature: "number",
temperatureApparent: "number",
temperatureDewPoint: "number",
uvIndex: "number",
visibility: "number",
windDirection: "number",
windGust: "number",
windSpeed: "number",
})
export type HourlyForecast = typeof hourlyForecastSchema.infer
const dayWeatherConditionsSchema = type({
conditionCode: conditionCodeSchema,
humidity: "number",
precipitationAmount: "number",
precipitationChance: "number",
precipitationType: precipitationTypeSchema,
snowfallAmount: "number",
temperatureMax: "number",
temperatureMin: "number",
windDirection: "number",
"windGust?": "number",
windSpeed: "number",
})
export type DayWeatherConditions = typeof dayWeatherConditionsSchema.infer
const dailyForecastSchema = type({
forecastStart: "string",
forecastEnd: "string",
conditionCode: conditionCodeSchema,
maxUvIndex: "number",
moonPhase: "string",
"moonrise?": "string",
"moonset?": "string",
precipitationAmount: "number",
precipitationChance: "number",
precipitationType: precipitationTypeSchema,
snowfallAmount: "number",
sunrise: "string",
sunriseCivil: "string",
sunriseNautical: "string",
sunriseAstronomical: "string",
sunset: "string",
sunsetCivil: "string",
sunsetNautical: "string",
sunsetAstronomical: "string",
temperatureMax: "number",
temperatureMin: "number",
"daytimeForecast?": dayWeatherConditionsSchema,
"overnightForecast?": dayWeatherConditionsSchema,
})
export type DailyForecast = typeof dailyForecastSchema.infer
const weatherAlertSchema = type({
id: "string",
areaId: "string",
areaName: "string",
certainty: certaintySchema,
countryCode: "string",
description: "string",
detailsUrl: "string",
effectiveTime: "string",
expireTime: "string",
issuedTime: "string",
responses: "string[]",
severity: severitySchema,
source: "string",
urgency: urgencySchema,
})
export type WeatherAlert = typeof weatherAlertSchema.infer
const weatherKitResponseSchema = type({
"currentWeather?": currentWeatherSchema,
"forecastHourly?": type({
hours: hourlyForecastSchema.array(),
}),
"forecastDaily?": type({
days: dailyForecastSchema.array(),
}),
"weatherAlerts?": type({
alerts: weatherAlertSchema.array(),
}),
})
export type WeatherKitResponse = typeof weatherKitResponseSchema.infer
async function generateJwt(credentials: WeatherKitCredentials): Promise<string> {
const header = {
alg: "ES256",
kid: credentials.keyId,
id: `${credentials.teamId}.${credentials.serviceId}`,
}
const now = Math.floor(Date.now() / 1000)
const payload = {
iss: credentials.teamId,
iat: now,
exp: now + 3600,
sub: credentials.serviceId,
}
const encoder = new TextEncoder()
const headerB64 = btoa(JSON.stringify(header))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
const payloadB64 = btoa(JSON.stringify(payload))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
const signingInput = `${headerB64}.${payloadB64}`
const pemContents = credentials.privateKey
.replace(/-----BEGIN PRIVATE KEY-----/, "")
.replace(/-----END PRIVATE KEY-----/, "")
.replace(/\s/g, "")
const binaryKey = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0))
const cryptoKey = await crypto.subtle.importKey(
"pkcs8",
binaryKey,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["sign"],
)
const signature = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
cryptoKey,
encoder.encode(signingInput),
)
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
return `${signingInput}.${signatureB64}`
}