Compare commits

..

43 Commits

Author SHA1 Message Date
f987253e53 Merge pull request #27 from kennethnym/feat/feed-source-actions
feat: add actions to FeedSource interface
2026-02-15 12:54:47 +00:00
699155e0d8 feat: add actions to FeedSource interface
Add listActions() and executeAction() to FeedSource for write
operations back to external services. Actions use arktype schemas
for input validation via StandardSchemaV1.

- ActionDefinition type with optional input schema
- FeedEngine routes actions with existence and ID validation
- Source IDs use reverse-domain format (aris.location, aris.tfl)
- LocationSource: update-location action with schema validation
- TflSource: set-lines-of-interest action with lineId validation
- No-op implementations for sources without actions

Co-authored-by: Ona <no-reply@ona.com>
2026-02-15 12:53:10 +00:00
4d6cac7ec8 Merge pull request #26 from kennethnym/refactor/required-fetch-context
refactor: make fetchContext required on FeedSource
2026-02-14 16:41:34 +00:00
1f2920a7ad refactor: make fetchContext required on FeedSource
Sources that cannot provide context now return null
instead of omitting the method. The engine checks the
return value rather than method existence.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 16:20:24 +00:00
476c6f06d9 Merge pull request #25 from kennethnym/feat/google-calendar-source
feat: add Google Calendar data source
2026-02-14 15:44:03 +00:00
bfdc5e67b7 chore: restore default comment on lookaheadHours
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:43:00 +00:00
2cf6536e48 refactor: move source options into source file
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:42:59 +00:00
c7a1048320 chore: remove obvious comments from types
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:42:59 +00:00
512faf191e feat: add Google Calendar data source
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:42:59 +00:00
e5d65816dc Merge pull request #24 from kennethnym/feat/apple-calendar-source
feat: add Apple Calendar source package
2026-02-14 15:42:04 +00:00
13c411c842 perf: cache fetched events within a refresh cycle
FeedEngine calls fetchContext then fetchItems with the same
context. Cache events by context.time reference to avoid
duplicate CalDAV round-trips.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:36:36 +00:00
e8ba49d7bb refactor: use switch/case in parser, move options
- Replace if/else chains with switch/case in ical-parser
- Move CalendarSourceOptions to calendar-source.ts

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:29:49 +00:00
3010eb8990 refactor: replace Map with Record in tests
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:20:23 +00:00
6c4982ae85 fix: use Promise.allSettled for calendar fetching
A transient error on one calendar (e.g. shared calendar
with permission issues) no longer discards results from
all other calendars.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 00:44:47 +00:00
f557a0f967 feat: add Apple Calendar source package
Add @aris/source-apple-calendar for fetching iCloud
calendar events via CalDAV using tsdav and ical.js.

- CalendarSource implements FeedSource with fetchItems
  and fetchContext for downstream context
- CalendarCredentialProvider interface for token injection
- CalendarDAVClient interface for testability
- iCal parser extracts full event data including
  attendees, alarms, organizer, and recurrence
- Priority based on event proximity to current time

Co-authored-by: Ona <no-reply@ona.com>
2026-02-13 22:08:58 +00:00
1b2929c2b6 Merge pull request #22 from kennethnym/feat/weather-service
feat(backend): add WeatherService
2026-02-13 20:05:06 +00:00
e5f1273baf feat(backend): add WeatherService
Manage per-user WeatherSource instances via FeedSourceProvider,
following the same pattern as LocationService. Wire into
FeedEngineService so weather data is included in the feed.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-13 20:02:58 +00:00
c3429e1a77 Merge pull request #23 from kennethnym/feat/tfl-service
feat(backend): add TflService
2026-02-13 20:01:03 +00:00
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
31a82c1d9f ci: run bun install in postCreateCommand
Co-authored-by: Ona <no-reply@ona.com>
2026-02-08 11:55:00 +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
84 changed files with 7576 additions and 462 deletions

View File

@@ -10,6 +10,7 @@
"context": ".",
"dockerfile": "Dockerfile"
},
"postCreateCommand": "bun install",
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh"
// Features add additional features to your environment. See https://containers.dev/features
// Beware: features are not supported on all platforms and may have unintended side-effects.

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
core

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,14 @@
# 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
# Apple WeatherKit credentials
WEATHERKIT_PRIVATE_KEY=
WEATHERKIT_KEY_ID=
WEATHERKIT_TEAM_ID=
WEATHERKIT_SERVICE_ID=

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,37 @@
import { TRPCError } from "@trpc/server"
import { type } from "arktype"
import type { TRPC } from "../trpc/router.ts"
import type { LocationService } from "./service.ts"
import { UserNotFoundError } from "../lib/error.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("aris.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,46 @@
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"
import { WeatherService } from "./weather/service.ts"
function main() {
const locationService = new LocationService()
const weatherService = new WeatherService({
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!,
},
})
const trpcRouter = createTRPCRouter({ locationService, weatherService })
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("aris.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("aris.tfl")
expect(service.feedSourceForUser("user-2").id).toBe("aris.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,50 @@
import { initTRPC, TRPCError } from "@trpc/server"
import type { LocationService } from "../location/service.ts"
import type { WeatherService } from "../weather/service.ts"
import type { Context } from "./context.ts"
import { createLocationRouter } from "../location/router.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
weatherService: WeatherService
}
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,116 @@
import type { Context } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import {
Units,
WeatherFeedItemType,
type WeatherKitClient,
type WeatherKitResponse,
} from "@aris/source-weatherkit"
import { describe, expect, test } from "bun:test"
import fixture from "../../../../packages/aris-source-weatherkit/fixtures/san-francisco.json"
import { WeatherService } from "./service.ts"
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
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("WeatherService", () => {
test("feedSourceForUser creates source on first call", () => {
const service = new WeatherService({ client: mockClient })
const source = service.feedSourceForUser("user-1")
expect(source).toBeDefined()
expect(source.id).toBe("aris.weather")
})
test("feedSourceForUser returns same source for same user", () => {
const service = new WeatherService({ client: mockClient })
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 WeatherService({ client: mockClient })
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-2")
expect(source1).not.toBe(source2)
})
test("feedSourceForUser applies hourly and daily limits", async () => {
const service = new WeatherService({
client: mockClient,
hourlyLimit: 3,
dailyLimit: 2,
})
const source = service.feedSourceForUser("user-1")
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourly = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const daily = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourly).toHaveLength(3)
expect(daily).toHaveLength(2)
})
test("feedSourceForUser applies units", async () => {
const service = new WeatherService({
client: mockClient,
units: Units.imperial,
})
const source = service.feedSourceForUser("user-1")
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const current = items.find((i) => i.type === WeatherFeedItemType.current)
expect(current).toBeDefined()
// Fixture has ~15.87°C, imperial should be ~60.6°F
expect(current!.data.temperature).toBeGreaterThan(50)
})
test("removeUser removes the source", () => {
const service = new WeatherService({ client: mockClient })
service.feedSourceForUser("user-1")
service.removeUser("user-1")
// After removal, feedSourceForUser should create a new instance
const source2 = service.feedSourceForUser("user-1")
expect(source2).toBeDefined()
})
test("removeUser allows new source to be created", () => {
const service = new WeatherService({ client: mockClient })
const source1 = service.feedSourceForUser("user-1")
service.removeUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).not.toBe(source2)
})
test("removeUser is no-op for unknown user", () => {
const service = new WeatherService({ client: mockClient })
expect(() => service.removeUser("unknown")).not.toThrow()
})
})

View File

@@ -0,0 +1,40 @@
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit"
import type { FeedSourceProvider } from "../feed/service.ts"
/**
* Options forwarded to every per-user WeatherSource.
* Must include either `credentials` or `client` (same requirement as WeatherSourceOptions).
*/
export type WeatherServiceOptions = WeatherSourceOptions
/**
* Manages WeatherSource instances per user.
*/
export class WeatherService implements FeedSourceProvider {
private sources = new Map<string, WeatherSource>()
private readonly options: WeatherServiceOptions
constructor(options: WeatherServiceOptions) {
this.options = options
}
/**
* Get or create a WeatherSource for a user.
*/
feedSourceForUser(userId: string): WeatherSource {
let source = this.sources.get(userId)
if (!source) {
source = new WeatherSource(this.options)
this.sources.set(userId, source)
}
return source
}
/**
* Remove a user's WeatherSource.
*/
removeUser(userId: string): void {
this.sources.delete(userId)
}
}

View File

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

223
bun.lock
View File

@@ -13,16 +13,30 @@
"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",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
},
},
"packages/aris-data-source-weatherkit": {
@@ -33,18 +47,85 @@
"arktype": "^2.1.0",
},
},
"packages/aris-source-apple-calendar": {
"name": "@aris/source-apple-calendar",
"version": "0.0.0",
"dependencies": {
"@aris/core": "workspace:*",
"ical.js": "^2.1.0",
"tsdav": "^2.1.7",
},
},
"packages/aris-source-google-calendar": {
"name": "@aris/source-google-calendar",
"version": "0.0.0",
"dependencies": {
"@aris/core": "workspace:*",
"arktype": "^2.1.0",
},
},
"packages/aris-source-location": {
"name": "@aris/source-location",
"version": "0.0.0",
"dependencies": {
"@aris/core": "workspace:*",
"arktype": "^2.1.0",
},
},
"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-apple-calendar": ["@aris/source-apple-calendar@workspace:packages/aris-source-apple-calendar"],
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
"@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.18", "", { "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-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="],
"@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=="],
@@ -61,40 +142,140 @@
"@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0tmlNzcyewAnauNeBCq0xmAkmiKzl+H09p0IdHy+QKrTQdtixtf+AOjDAADbRfihkS+heF15Pjc4IyJMdAAJjw=="],
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.39.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lT3hNhIa02xCujI6YGgjmYGg3Ht/X9ag5ipUVETaMpx5Rd4BbTNWUPif1WN1YZHxt3KLCIqaAe7zVhatv83HOQ=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.47.0", "", { "os": "android", "cpu": "arm" }, "sha512-UHqo3te9K/fh29brCuQdHjN+kfpIi9cnTPABuD5S9wb9ykXYRGTOOMVuSV/CK43sOhU4wwb2nT1RVjcbrrQjFw=="],
"@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.39.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UT+rfTWd+Yr7iJeSLd/7nF8X4gTYssKh+n77hxl6Oilp3NnG1CKRHxZDy3o3lIBnwgzJkdyUAiYWO1bTMXQ1lA=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.47.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xh02lsTF1TAkR+SZrRMYHR/xCx8Wg2MAHxJNdHVpAKELh9/yE9h4LJeqAOBbIb3YYn8o/D97U9VmkvkfJfrHfw=="],
"@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.39.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qocBkvS2V6rH0t9AT3DfQunMnj3xkM7srs5/Ycj2j5ZqMoaWd/FxHNVJDFP++35roKSvsRJoS0mtA8/77jqm6Q=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.47.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OSOfNJqabOYbkyQDGT5pdoL+05qgyrmlQrvtCO58M4iKGEQ/xf3XkkKj7ws+hO+k8Y4VF4zGlBsJlwqy7qBcHA=="],
"@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.39.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-arZzAc1PPcz9epvGBBCMHICeyQloKtHX3eoOe62B3Dskn7gf6Q14wnDHr1r9Vp4vtcBATNq6HlKV14smdlC/qA=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.47.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hP2bOI4IWNS+F6pVXWtRshSTuJ1qCRZgDgVUg6EBUqsRy+ExkEPJkx+YmIuxgdCduYK1LKptLNFuQLJP8voPbQ=="],
"@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.39.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZVt5qsECpuNprdWxAPpDBwoixr1VTcZ4qAEQA2l/wmFyVPDYFD3oBY/SWACNnWBddMrswjTg9O8ALxYWoEpmXw=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.47.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-F55jIEH5xmGu7S661Uho8vGiLFk0bY3A/g4J8CTKiLJnYu/PSMZ2WxFoy5Hji6qvFuujrrM9Q8XXbMO0fKOYPg=="],
"@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.39.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pB0hlGyKPbxr9NMIV783lD6cWL3MpaqnZRM9MWni4yBdHPTKyFNYdg5hGD0Bwg+UP4S2rOevq/+OO9x9Bi7E6g=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wxmOn/wns/WKPXUC1fo5mu9pMZPVOu8hsynaVDrgmmXMdHKS7on6bA5cPauFFN9tJXNdsjW26AK9lpfu3IfHBQ=="],
"@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.39.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gg2SFaJohI9+tIQVKXlPw3FsPQFi/eCSWiCgwPtPn5uzQxHRTeQEZKuluz1fuzR5U70TXubb2liZi4Dgl8LJQA=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KJTmVIA/GqRlM2K+ZROH30VMdydEU7bDTY35fNg3tOPzQRIs2deLZlY/9JWwdWo1F/9mIYmpbdCmPqtKhWNOPg=="],
"@oxlint/win32-x64": ["@oxlint/win32-x64@1.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sbi25lfj74hH+6qQtb7s1wEvd1j8OQbTaH8v3xTcDjrwm579Cyh0HBv1YSZ2+gsnVwfVDiCTL1D0JsNqYXszVA=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PF7ELcFg1GVlS0X0ZB6aWiXobjLrAKer3T8YEkwIoO8RwWiAMkL3n3gbleg895BuZkHVlJ2kPRUwfrhHrVkD1A=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4BezLRO5cu0asf0Jp1gkrnn2OHiXrPPPEfBTxq1k5/yJ2zdGGTmZxHD2KF2voR23wb8Elyu3iQawXo7wvIZq0Q=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.47.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aI5ds9jq2CPDOvjeapiIj48T/vlWp+f4prkxs+FVzrmVN9BWIj0eqeJ/hV8WgXg79HVMIz9PU6deI2ki09bR1w=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-mO7ycp9Elvgt5EdGkQHCwJA6878xvo9tk+vlMfT1qg++UjvOMB8INsOCQIOH2IKErF/8/P21LULkdIrocMw9xA=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-24D0wsYT/7hDFn3Ow32m3/+QT/1ZwrUhShx4/wRDAmz11GQHOZ1k+/HBuK/MflebdnalmXWITcPEy4BWTi7TCA=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.47.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8tPzPne882mtML/uy3mApvdCyuVOpthJ7xUv3b67gVfz63hOOM/bwO0cysSkPyYYFDFRn6/FnUb7Jhmsesntvg=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-q58pIyGIzeffEBhEgbRxLFHmHfV9m7g1RnkLiahQuEvyjKNiJcvdHOwKH2BdgZxdzc99Cs6hF5xTa86X40WzPw=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-e7DiLZtETZUCwTa4EEHg9G+7g3pY+afCWXvSeMG7m0TQ29UHHxMARPaEQUE4mfKgSqIWnJaUk2iZzRPMRdga5g=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.47.0", "", { "os": "none", "cpu": "arm64" }, "sha512-3AFPfQ0WKMleT/bKd7zsks3xoawtZA6E/wKf0DjwysH7wUiMMJkNKXOzYq1R/00G98JFgSU1AkrlOQrSdNNhlg=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.47.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-cLMVVM6TBxp+N7FldQJ2GQnkcLYEPGgiuEaXdvhgvSgODBk9ov3jed+khIXSAWtnFOW0wOnG3RjwqPh0rCuheA=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.47.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-VpFOSzvTnld77/Edje3ZdHgZWnlTb5nVWXyTgjD3/DKF/6t5bRRbwn3z77zOdnGy44xAMvbyAwDNOSeOdVUmRA=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.47.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+q8IWptxXx2HMTM6JluR67284t0h8X/oHJgqpxH1siowxPMqZeIpAcWCUq+tY+Rv2iQK8TUugjZnSBQAVV5CmA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@trpc/server": ["@trpc/server@11.10.0", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-zZjTrR6He61e5TiT7e/bQqab/jRcXBZM8Fg78Yoo8uh5pz60dzzbYuONNUCOkafv5ppXVMms4NHYfNZgzw50vg=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"@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=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
"better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@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-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="],
"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.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"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=="],
"oxlint": ["oxlint@1.47.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.47.0", "@oxlint/binding-android-arm64": "1.47.0", "@oxlint/binding-darwin-arm64": "1.47.0", "@oxlint/binding-darwin-x64": "1.47.0", "@oxlint/binding-freebsd-x64": "1.47.0", "@oxlint/binding-linux-arm-gnueabihf": "1.47.0", "@oxlint/binding-linux-arm-musleabihf": "1.47.0", "@oxlint/binding-linux-arm64-gnu": "1.47.0", "@oxlint/binding-linux-arm64-musl": "1.47.0", "@oxlint/binding-linux-ppc64-gnu": "1.47.0", "@oxlint/binding-linux-riscv64-gnu": "1.47.0", "@oxlint/binding-linux-riscv64-musl": "1.47.0", "@oxlint/binding-linux-s390x-gnu": "1.47.0", "@oxlint/binding-linux-x64-gnu": "1.47.0", "@oxlint/binding-linux-x64-musl": "1.47.0", "@oxlint/binding-openharmony-arm64": "1.47.0", "@oxlint/binding-win32-arm64-msvc": "1.47.0", "@oxlint/binding-win32-ia32-msvc": "1.47.0", "@oxlint/binding-win32-x64-msvc": "1.47.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-v7xkK1iv1qdvTxJGclM97QzN8hHs5816AneFAQ0NGji1BMUquhiDAhXpMwp8+ls16uRVJtzVHxP9pAAXblDeGA=="],
"pg": ["pg@8.18.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "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-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="],
"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=="],
"sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
"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=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tsdav": ["tsdav@2.1.8", "", { "dependencies": { "base-64": "1.0.0", "cross-fetch": "4.1.0", "debug": "4.4.3", "xml-js": "1.6.11" } }, "sha512-zvQvhZLzTaEmNNgJbBlUYT/JOq9Xpw/xkxCqs7IT2d2/7o7pss0iZOlZXuHJ5VcvSvTny42Vc6+6GyzZcrCJ1g=="],
"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=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
}
}

View File

@@ -0,0 +1,306 @@
# Backend Service Architecture: Per-User Refactor
## Problem Statement
The current backend uses a **per-source service** pattern: each source type (Location, Weather, TFL) has its own `XxxService` class that manages a `Map<userId, SourceInstance>`. Adding a new source requires:
1. A new `XxxService` class with identical boilerplate (~30-40 lines: Map, get-or-create, removeUser)
2. Wiring it into `server.ts` constructor
3. Passing it to `FeedEngineService`
4. Optionally adding source-specific tRPC routes
With 3 sources this is manageable. With 10+ (calendar, music, transit, news, etc.) it becomes:
- **Repetitive**: Every service class repeats the same Map + get-or-create + removeUser pattern
- **Fragmented lifecycle**: User cleanup requires calling `removeUser` on every service independently
- **No user-level config**: No unified place to store which sources a user has enabled or their per-source settings
- **Hard to reason about**: User state is scattered across N independent Maps
### Current Flow
```
server.ts
├── new LocationService() ← owns Map<userId, LocationSource>
├── new WeatherService(creds) ← owns Map<userId, WeatherSource>
├── new TflService(api) ← owns Map<userId, TflSource>
└── FeedEngineService([loc, weather, tfl])
└── owns Map<userId, FeedEngine>
└── on create: asks each service for feedSourceForUser(userId)
```
4 independent Maps for 3 sources. Each user's state lives in 4 different places.
## Scope
**Backend only** (`apps/aris-backend`). No changes to `aris-core` or source packages (`packages/aris-source-*`). The `FeedSource` interface and source implementations remain unchanged.
## Architectural Options
### Option A: UserSession Object
A single `UserSession` class owns everything for one user. A `UserSessionManager` is the only top-level Map.
```typescript
class UserSession {
readonly userId: string
readonly engine: FeedEngine
private sources: Map<string, FeedSource>
constructor(userId: string, sourceFactories: SourceFactory[]) {
this.engine = new FeedEngine()
this.sources = new Map()
for (const factory of sourceFactories) {
const source = factory.create()
this.sources.set(source.id, source)
this.engine.register(source)
}
this.engine.start()
}
getSource<T extends FeedSource>(id: string): T | undefined {
return this.sources.get(id) as T | undefined
}
destroy(): void {
this.engine.stop()
this.sources.clear()
}
}
class UserSessionManager {
private sessions = new Map<string, UserSession>()
getOrCreate(userId: string): UserSession { ... }
remove(userId: string): void { ... } // single cleanup point
}
```
**Source-specific operations** use typed accessors:
```typescript
const session = manager.getOrCreate(userId)
const location = session.getSource<LocationSource>("location")
location?.pushLocation({ lat: 51.5, lng: -0.1, ... })
```
**Pros:**
- Single Map, single cleanup point
- All user state co-located
- Easy to add TTL/eviction at one level
- Source factories are simple functions, no service classes needed
**Cons:**
- `getSource<T>("id")` requires callers to know the source ID string and cast type
- Shared resources (e.g., TFL API client) need to be passed through factories
### Option B: Source Registry with Factories
Keep `FeedEngineService` but replace per-source service classes with a registry of factory functions. No `XxxService` classes at all.
```typescript
interface SourceFactory {
readonly sourceId: string
create(userId: string): FeedSource
}
// Weather factory — closure over shared credentials
function weatherSourceFactory(creds: WeatherKitCredentials): SourceFactory {
return {
sourceId: "weather",
create: () => new WeatherSource({ credentials: creds }),
}
}
// TFL factory — closure over shared API client
function tflSourceFactory(api: ITflApi): SourceFactory {
return {
sourceId: "tfl",
create: () => new TflSource({ client: api }),
}
}
class FeedEngineService {
private engines = new Map<string, FeedEngine>()
private userSources = new Map<string, Map<string, FeedSource>>()
constructor(private readonly factories: SourceFactory[]) {}
engineForUser(userId: string): FeedEngine { ... }
getSourceForUser<T extends FeedSource>(userId: string, sourceId: string): T | undefined { ... }
removeUser(userId: string): void { ... } // cleans up engine + all sources
}
```
**Pros:**
- Minimal change from current structure — `FeedEngineService` evolves, services disappear
- Factory functions are 5-10 lines each, no classes
- Shared resources handled naturally via closures
**Cons:**
- `FeedEngineService` grows in responsibility (engine + source tracking + source access)
- Still two Maps (engines + userSources), though co-located
### Option C: UserSession + Typed Source Handles (Recommended)
Combines Option A's co-location with type-safe source access. `UserSession` owns everything. Source-specific operations go through **source handles** — thin typed wrappers registered at setup time.
```typescript
// Source handle: typed wrapper for source-specific operations
interface SourceHandle<T extends FeedSource = FeedSource> {
readonly source: T
}
class UserSession {
readonly engine: FeedEngine
private handles = new Map<string, SourceHandle>()
register<T extends FeedSource>(source: T): SourceHandle<T> {
this.engine.register(source)
const handle: SourceHandle<T> = { source }
this.handles.set(source.id, handle)
return handle
}
destroy(): void {
this.engine.stop()
this.handles.clear()
}
}
// In setup code — handles are typed at creation time
function createSession(userId: string, deps: SessionDeps): UserSession {
const session = new UserSession(userId)
const locationHandle = session.register(new LocationSource())
const weatherHandle = session.register(new WeatherSource(deps.weatherCreds))
const tflHandle = session.register(new TflSource({ client: deps.tflApi }))
return session
}
```
**Source-specific operations** use the typed handles returned at registration:
```typescript
// In the tRPC router or wherever source-specific ops happen:
// The handle is obtained during session setup and stored where needed
locationHandle.source.pushLocation({ ... })
tflHandle.source.setLinesOfInterest(["northern"])
```
**Pros:**
- Single Map, single cleanup
- Type-safe source access without string-based lookups or casts
- No boilerplate service classes
- Handles can be extended later (e.g., add per-source config, metrics)
- Shared resources passed directly to constructors
**Cons:**
- Handles need to be threaded to where they're used (tRPC routers, etc.)
- Slightly more setup code in the factory function
## Source-Specific Operations: Approaches
Orthogonal to the session model, there are three ways to handle operations like `pushLocation` or `setLinesOfInterest`:
### Approach 1: Direct Source Access (Recommended)
Callers get a typed reference to the source and call methods directly. This is what all three options above use in different ways.
```typescript
locationSource.pushLocation(location)
tflSource.setLinesOfInterest(lines)
```
**Why this works:** Source packages already define these methods. The backend just needs to expose the source instance to the right caller. No new abstraction needed.
### Approach 2: Command Dispatch
A generic `dispatch(command)` method on the session routes typed commands to sources.
```typescript
session.dispatch({ type: "location.update", payload: { lat: 51.5, ... } })
```
**Tradeoff:** Adds indirection and a command type registry. Useful if sources are dynamically loaded plugins, but over-engineered for the current case where sources are known at compile time.
### Approach 3: Context-Only
All input goes through `FeedEngine` context updates. Sources react to context changes.
```typescript
engine.pushContext({ [LocationKey]: location })
// LocationSource picks this up via onContextUpdate
```
**Tradeoff:** Location already works this way (it's a context provider). But not all operations map to context — `setLinesOfInterest` is configuration, not context. Would require stretching the context concept.
## User Source Configuration (DB-Persisted)
Regardless of which option is chosen, user source config needs a storage model:
```sql
CREATE TABLE user_source_config (
user_id TEXT NOT NULL REFERENCES users(id),
source_id TEXT NOT NULL, -- e.g., "weather", "tfl", "location"
enabled BOOLEAN NOT NULL DEFAULT true,
config JSONB NOT NULL DEFAULT '{}', -- source-specific settings
PRIMARY KEY (user_id, source_id)
);
```
On session creation:
1. Load `user_source_config` rows for the user
2. Only create sources where `enabled = true`
3. Pass `config` JSON to the source factory/constructor
New users get default config rows inserted on first login.
## Recommendation
**Option C (UserSession + Typed Source Handles)** with **Approach 1 (Direct Source Access)**.
Rationale:
- Eliminates all per-source service boilerplate
- Single user lifecycle management point
- Type-safe without string-based lookups in hot paths
- Minimal new abstraction — `UserSession` is a thin container, not a framework
- Handles are just typed references, not a new pattern to learn
- Natural extension point for per-user config loading from DB
## Acceptance Criteria
1. **No per-source service classes**: `LocationService`, `WeatherService`, `TflService` are removed
2. **Single user state container**: All per-user state (engine, sources) lives in one object
3. **Single cleanup**: Removing a user requires one call, not N
4. **Type-safe source access**: Source-specific operations don't require string-based lookups or unsafe casts at call sites
5. **Existing tests pass**: `FeedEngineService` tests are migrated to the new structure
6. **tRPC routes work**: Location update route works through the new architecture
7. **DB config table**: `user_source_config` table exists; session creation reads from it
8. **Default config**: New users get default source config on first session
## Implementation Steps
1. Create `user_source_config` DB table and migration
2. Create `UserSession` class with `register()`, `destroy()`, typed handle return
3. Create `UserSessionManager` with `getOrCreate()`, `remove()`, config loading
4. Create `createSession()` factory that reads DB config and registers enabled sources
5. Refactor `server.ts` to use `UserSessionManager` instead of individual services
6. Refactor tRPC router to receive session/handles instead of individual services
7. Delete `LocationService`, `WeatherService`, `TflService` classes
8. Migrate existing tests to new structure
9. Add tests for session lifecycle (create, destroy, config loading)
## Open Questions
- **TTL/eviction**: Should `UserSessionManager` handle idle session cleanup? (Currently deferred in backend-spec.md)
- **Hot reload config**: If a user changes their source config, should the session be recreated or patched in-place?
- **Shared source instances**: Some sources (e.g., TFL) share an API client. Should the factory receive shared deps, or should there be a DI container?

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

@@ -0,0 +1,193 @@
# 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

@@ -0,0 +1,269 @@
# FeedSource Actions
## Problem Statement
`FeedSource` is read-only. Sources can provide context and feed items but can't expose write operations (play, RSVP, dismiss). This blocks interactive sources like Spotify, calendar, and tasks.
## Scope
**`aris-core` only.** Add action support to `FeedSource` and `FeedItem`. No changes to existing fields or methods — purely additive.
## Design
### Why Not MCP
MCP was considered. It doesn't fit because:
- MCP resources don't accept input context (FeedSource needs accumulated context as input)
- MCP has no structured feed items (priority, timestamp, type)
- MCP's isolation model conflicts with ARIS's dependency graph
- Adding these as MCP extensions would mean the extensions are the entire protocol
The interface is designed to be **protocol-compatible** — a future `RemoteFeedSource` adapter can map each field/method to a JSON-RPC operation without changing the interface:
| FeedSource field/method | Future protocol operation |
| ----------------------- | ------------------------- |
| `id`, `dependencies` | `source/describe` |
| `listActions()` | `source/listActions` |
| `fetchContext()` | `source/fetchContext` |
| `fetchItems()` | `source/fetchItems` |
| `executeAction()` | `source/executeAction` |
| `onContextUpdate()` | `source/contextUpdated` |
| `onItemsUpdate()` | `source/itemsUpdated` |
No interface changes needed when the transport layer is built.
### Source ID & Action ID Convention
Source IDs use reverse domain notation. Built-in sources use `aris.<name>`. Third parties use their own domain.
Action IDs are descriptive verb-noun pairs in kebab-case, scoped to their source. The globally unique form is `<sourceId>/<actionId>`.
| Source ID | Action IDs |
| --------------- | -------------------------------------------------------------- |
| `aris.location` | `update-location` (migrated from `pushLocation()`) |
| `aris.tfl` | `set-lines-of-interest` (migrated from `setLinesOfInterest()`) |
| `aris.weather` | _(none)_ |
| `com.spotify` | `play-track`, `pause-playback`, `skip-track`, `like-track` |
| `aris.calendar` | `rsvp`, `create-event` |
| `com.todoist` | `complete-task`, `snooze-task` |
This means existing source packages need their `id` updated (e.g., `"location"``"aris.location"`).
### New Types
```typescript
/** Describes an action a source can perform. */
interface ActionDefinition<TInput = unknown> {
/** Descriptive action name in kebab-case (e.g., "update-location", "play-track") */
readonly id: string
/** Human-readable label for UI (e.g., "Play", "RSVP Yes") */
readonly label: string
/** Optional longer description */
readonly description?: string
/** Schema for input validation. Accepts any Standard Schema compatible validator (arktype, zod, valibot, etc.). Omit if no params. */
readonly input?: StandardSchemaV1<TInput>
}
```
`StandardSchemaV1` is the [Standard Schema](https://github.com/standard-schema/standard-schema) interface implemented by arktype, zod, and valibot. This means sources can use any validator:
```typescript
import { type } from "arktype"
import { z } from "zod"
// With arktype
{ id: "play-track", label: "Play", input: type({ trackId: "string" }) }
// With zod
{ id: "play-track", label: "Play", input: z.object({ trackId: z.string() }) }
// Without validation (e.g., remote sources using raw JSON Schema)
{ id: "play-track", label: "Play" }
/** Result of executing an action. */
interface ActionResult {
ok: boolean
data?: Record<string, unknown>
error?: string
}
/** Reference to an action on a specific feed item. */
interface ItemAction {
/** Action ID (matches ActionDefinition.id on the source) */
actionId: string
/** Per-item label override (e.g., "RSVP to standup") */
label?: string
/** Pre-filled params for this item (e.g., { eventId: "abc" }) */
params?: Record<string, unknown>
}
```
### Changes to FeedSource
Two optional fields added. Nothing else changes.
```typescript
interface FeedSource<TItem extends FeedItem = FeedItem> {
readonly id: string // unchanged
readonly dependencies?: readonly string[] // unchanged
fetchContext(...): ... // unchanged
onContextUpdate?(...): ... // unchanged
fetchItems?(...): ... // unchanged
onItemsUpdate?(...): ... // unchanged
/** List actions this source supports. Empty record if none. Maps to: source/listActions */
listActions(): Promise<Record<string, ActionDefinition>>
/** Execute an action by ID. No-op returning { ok: false } if source has no actions. */
executeAction(
actionId: string,
params: Record<string, unknown>,
): Promise<ActionResult>
}
```
### Changes to FeedItem
One optional field added.
```typescript
interface FeedItem<
TType extends string = string,
TData extends Record<string, unknown> = Record<string, unknown>,
> {
id: string // unchanged
type: TType // unchanged
priority: number // unchanged
timestamp: Date // unchanged
data: TData // unchanged
/** Actions the user can take on this item. */
actions?: readonly ItemAction[]
}
```
### Changes to FeedEngine
Two new methods. Existing methods unchanged.
```typescript
class FeedEngine {
// All existing methods unchanged...
/** Route an action call to the correct source. */
async executeAction(
sourceId: string,
actionId: string,
params: Record<string, unknown>,
): Promise<ActionResult>
/** List all actions across all registered sources. */
listActions(): { sourceId: string; actions: readonly ActionDefinition[] }[]
}
```
### Example: Spotify Source
```typescript
class SpotifySource implements FeedSource<SpotifyFeedItem> {
readonly id = "com.spotify"
async listActions() {
return {
"play-track": { id: "play-track", label: "Play", input: type({ trackId: "string" }) },
"pause-playback": { id: "pause-playback", label: "Pause" },
"skip-track": { id: "skip-track", label: "Skip" },
"like-track": { id: "like-track", label: "Like", input: type({ trackId: "string" }) },
}
}
async executeAction(actionId: string, params: Record<string, unknown>): Promise<ActionResult> {
switch (actionId) {
case "play-track":
await this.client.play(params.trackId as string)
return { ok: true }
case "pause-playback":
await this.client.pause()
return { ok: true }
case "skip-track":
await this.client.skip()
return { ok: true }
case "like-track":
await this.client.like(params.trackId as string)
return { ok: true }
default:
return { ok: false, error: `Unknown action: ${actionId}` }
}
}
async fetchContext(): Promise<null> {
return null
}
// Note: for a source with no actions, it would be:
// async listActions() { return {} }
// async executeAction(): Promise<ActionResult> {
// return { ok: false, error: "No actions supported" }
// }
async fetchItems(context: Context): Promise<SpotifyFeedItem[]> {
const track = await this.client.getCurrentTrack()
if (!track) return []
return [
{
id: `spotify-${track.id}`,
type: "spotify-now-playing",
priority: 0.4,
timestamp: context.time,
data: { trackName: track.name, artist: track.artist },
actions: [
{ actionId: "pause-playback" },
{ actionId: "skip-track" },
{ actionId: "like-track", params: { trackId: track.id } },
],
},
]
}
}
```
## Acceptance Criteria
1. `ActionDefinition` type exists with `id`, `label`, `description?`, `inputSchema?`
2. `ActionResult` type exists with `ok`, `data?`, `error?`
3. `ItemAction` type exists with `actionId`, `label?`, `params?`
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
8. `FeedEngine.listActions()` aggregates actions from all sources
9. Existing tests pass unchanged (all changes are additive)
10. New tests: action execution, unknown action ID, unknown source ID, source without actions, `listActions()` aggregation
## Implementation Steps
1. Create `action.ts` in `aris-core/src` with `ActionDefinition`, `ActionResult`, `ItemAction`
2. Add optional `actions` and `executeAction` to `FeedSource` interface in `feed-source.ts`
3. Add optional `actions` field to `FeedItem` interface in `feed.ts`
4. Add `executeAction()` and `listActions()` to `FeedEngine` in `feed-engine.ts`
5. Export new types from `aris-core/index.ts`
6. Add tests for `FeedEngine.executeAction()` routing
7. Add tests for `FeedEngine.listActions()` aggregation
8. Add tests for error cases (unknown action, unknown source, source without actions)
9. Update source IDs to reverse-domain format (`"location"``"aris.location"`, etc.) across all source packages
10. Migrate `LocationSource.pushLocation()` → action `update-location` on `aris.location`
11. Migrate `TflSource.setLinesOfInterest()` → action `set-lines-of-interest` on `aris.tfl`
12. Add `async listActions() { return {} }` and no-op `executeAction()` to sources without actions (WeatherSource, GoogleCalendarSource, AppleCalendarSource)
13. Update any tests or code referencing old source IDs
14. Run all tests to confirm nothing breaks
## What This Defers
- Transport layer (JSON-RPC over HTTP/WebSocket) — built when remote sources are needed
- `RemoteFeedSource` adapter — mechanical once transport exists
- MCP adapter — wraps MCP servers as FeedSource
- Runtime schema validation of action params
- Action permissions / confirmation UI
- Source discovery / registry API
- Backend service consolidation (separate spec, depends on this one)

View File

@@ -2,9 +2,12 @@
"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 ."
},
"dependencies": {
"@standard-schema/spec": "^1.1.0"
}
}

View File

@@ -0,0 +1,27 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
/**
* Describes an action a source can perform.
*
* Action IDs use descriptive verb-noun kebab-case (e.g., "update-location", "play-track").
* Combined with the source's reverse-domain ID, they form a globally unique identifier:
* `<sourceId>/<actionId>` (e.g., "aris.location/update-location").
*/
export class UnknownActionError extends Error {
readonly actionId: string
constructor(actionId: string) {
super(`Unknown action: ${actionId}`)
this.name = "UnknownActionError"
this.actionId = actionId
}
}
export interface ActionDefinition<TInput = unknown> {
/** Descriptive action name in kebab-case (e.g., "update-location", "play-track") */
readonly id: string
/** Optional longer description */
readonly description?: string
/** Schema for input validation. Accepts any Standard Schema compatible validator (arktype, zod, valibot, etc.). */
readonly input?: StandardSchemaV1<TInput>
}

View File

@@ -0,0 +1,641 @@
import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine"
import { UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
},
}
// =============================================================================
// 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",
...noActions,
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"],
...noActions,
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return null
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"],
...noActions,
async fetchContext() {
return null
},
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", async () => {
const engine = new FeedEngine()
const orphan: FeedSource = {
id: "orphan",
dependencies: ["nonexistent"],
...noActions,
async fetchContext() {
return null
},
}
engine.register(orphan)
await expect(engine.refresh()).rejects.toThrow(
'Source "orphan" depends on "nonexistent" which is not registered',
)
})
test("throws on circular dependency", async () => {
const engine = new FeedEngine()
const a: FeedSource = {
id: "a",
dependencies: ["b"],
...noActions,
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
...noActions,
async fetchContext() {
return null
},
}
engine.register(a).register(b)
await expect(engine.refresh()).rejects.toThrow("Circular dependency detected: a → b → a")
})
test("throws on longer cycles", async () => {
const engine = new FeedEngine()
const a: FeedSource = {
id: "a",
dependencies: ["c"],
...noActions,
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
...noActions,
async fetchContext() {
return null
},
}
const c: FeedSource = {
id: "c",
dependencies: ["b"],
...noActions,
async fetchContext() {
return null
},
}
engine.register(a).register(b).register(c)
await 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",
...noActions,
async fetchContext() {
order.push("location")
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
},
}
const weather: FeedSource = {
id: "weather",
dependencies: ["location"],
...noActions,
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",
...noActions,
async fetchContext() {
return null // 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",
...noActions,
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",
...noActions,
async fetchContext() {
return null
},
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",
...noActions,
async fetchContext() {
throw new Error("Failed")
},
}
const working: FeedSource = {
id: "working",
...noActions,
async fetchContext() {
return null
},
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()
})
})
describe("executeAction", () => {
test("routes action to correct source", async () => {
let receivedAction = ""
let receivedParams: unknown = {}
const source: FeedSource = {
id: "test-source",
async listActions() {
return {
"do-thing": { id: "do-thing" },
}
},
async executeAction(actionId, params) {
receivedAction = actionId
receivedParams = params
},
async fetchContext() {
return null
},
}
const engine = new FeedEngine().register(source)
await engine.executeAction("test-source", "do-thing", { key: "value" })
expect(receivedAction).toBe("do-thing")
expect(receivedParams).toEqual({ key: "value" })
})
test("throws for unknown source", async () => {
const engine = new FeedEngine()
await expect(engine.executeAction("nonexistent", "action", {})).rejects.toThrow(
"Source not found: nonexistent",
)
})
test("throws for unknown action on source", async () => {
const source: FeedSource = {
id: "test-source",
...noActions,
async fetchContext() {
return null
},
}
const engine = new FeedEngine().register(source)
await expect(engine.executeAction("test-source", "nonexistent", {})).rejects.toThrow(
'Action "nonexistent" not found on source "test-source"',
)
})
})
describe("listActions", () => {
test("returns actions for a specific source", async () => {
const source: FeedSource = {
id: "test-source",
async listActions() {
return {
"action-1": { id: "action-1" },
"action-2": { id: "action-2" },
}
},
async executeAction() {},
async fetchContext() {
return null
},
}
const engine = new FeedEngine().register(source)
const actions = await engine.listActions("test-source")
expect(Object.keys(actions)).toEqual(["action-1", "action-2"])
})
test("throws for unknown source", async () => {
const engine = new FeedEngine()
await expect(engine.listActions("nonexistent")).rejects.toThrow(
"Source not found: nonexistent",
)
})
test("throws on mismatched action ID", async () => {
const source: FeedSource = {
id: "bad-source",
async listActions() {
return {
"correct-key": { id: "wrong-id" },
}
},
async executeAction() {},
async fetchContext() {
return null
},
}
const engine = new FeedEngine().register(source)
await expect(engine.listActions("bad-source")).rejects.toThrow(
'Action ID mismatch on source "bad-source"',
)
})
})
})

View File

@@ -0,0 +1,380 @@
import type { ActionDefinition } from "./action"
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) {
try {
const update = await source.fetchContext(context)
if (update) {
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
}
/**
* Execute an action on a registered source.
* Validates the action exists before dispatching.
*
* In pull-only mode (before `start()` is called), the action mutates source
* state but does not automatically refresh dependents. Call `refresh()`
* after to propagate changes. In reactive mode (`start()` called), sources
* that push context updates (e.g., LocationSource) will trigger dependent
* refresh automatically.
*/
async executeAction(sourceId: string, actionId: string, params: unknown): Promise<unknown> {
const actions = await this.listActions(sourceId)
if (!(actionId in actions)) {
throw new Error(`Action "${actionId}" not found on source "${sourceId}"`)
}
return this.sources.get(sourceId)!.executeAction(actionId, params)
}
/**
* List actions available on a specific source.
* Validates that action definition IDs match their record keys.
*/
async listActions(sourceId: string): Promise<Record<string, ActionDefinition>> {
const source = this.sources.get(sourceId)
if (!source) {
throw new Error(`Source not found: ${sourceId}`)
}
const actions = await source.listActions()
for (const [key, definition] of Object.entries(actions)) {
if (key !== definition.id) {
throw new Error(
`Action ID mismatch on source "${sourceId}": key "${key}" !== definition.id "${definition.id}"`,
)
}
}
return actions
}
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) {
try {
const update = await source.fetchContext(this.context)
if (update) {
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

@@ -1,8 +1,18 @@
import { describe, expect, test } from "bun:test"
import type { Context, ContextKey, FeedItem, FeedSource } from "./index"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { contextKey, contextValue } from "./index"
import { UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
},
}
// =============================================================================
// CONTEXT KEYS
@@ -42,6 +52,7 @@ function createLocationSource(): SimulatedLocationSource {
return {
id: "location",
...noActions,
onContextUpdate(cb) {
callback = cb
@@ -70,10 +81,11 @@ function createWeatherSource(
return {
id: "weather",
dependencies: ["location"],
...noActions,
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
if (!location) return null
const weather = await fetchWeather(location)
return { [WeatherKey]: weather }
@@ -103,6 +115,11 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
return {
id: "alert",
dependencies: ["weather"],
...noActions,
async fetchContext() {
return null
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
@@ -194,8 +211,8 @@ async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; ite
// Run fetchContext in topological order
for (const source of graph.sorted) {
if (source.fetchContext) {
const update = await source.fetchContext(context)
const update = await source.fetchContext(context)
if (update) {
context = { ...context, ...update }
}
}
@@ -245,9 +262,15 @@ describe("FeedSource", () => {
expect(source.id).toBe("alert")
expect(source.dependencies).toEqual(["weather"])
expect(source.fetchContext).toBeUndefined()
expect(source.fetchContext).toBeDefined()
expect(source.fetchItems).toBeDefined()
})
test("source without context returns null from fetchContext", async () => {
const source = createAlertSource()
const result = await source.fetchContext({ time: new Date() })
expect(result).toBeNull()
})
})
describe("graph validation", () => {
@@ -255,6 +278,10 @@ describe("FeedSource", () => {
const orphan: FeedSource = {
id: "orphan",
dependencies: ["nonexistent"],
...noActions,
async fetchContext() {
return null
},
}
expect(() => buildGraph([orphan])).toThrow(
@@ -263,16 +290,51 @@ describe("FeedSource", () => {
})
test("detects circular dependencies", () => {
const a: FeedSource = { id: "a", dependencies: ["b"] }
const b: FeedSource = { id: "b", dependencies: ["a"] }
const a: FeedSource = {
id: "a",
dependencies: ["b"],
...noActions,
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
...noActions,
async fetchContext() {
return null
},
}
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"] }
const a: FeedSource = {
id: "a",
dependencies: ["c"],
...noActions,
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
...noActions,
async fetchContext() {
return null
},
}
const c: FeedSource = {
id: "c",
dependencies: ["b"],
...noActions,
async fetchContext() {
return null
},
}
expect(() => buildGraph([a, b, c])).toThrow("Circular dependency detected")
})
@@ -307,6 +369,7 @@ describe("FeedSource", () => {
const location: FeedSource = {
id: "location",
...noActions,
async fetchContext() {
order.push("location")
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
@@ -316,6 +379,7 @@ describe("FeedSource", () => {
const weather: FeedSource = {
id: "weather",
dependencies: ["location"],
...noActions,
async fetchContext(ctx) {
order.push("weather")
const loc = contextValue(ctx, LocationKey)
@@ -339,8 +403,14 @@ describe("FeedSource", () => {
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" })
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 () => {
@@ -376,12 +446,13 @@ describe("FeedSource", () => {
})
test("source without location context returns empty items", async () => {
// Location source exists but hasn't been updated (returns default 0,0)
// Location source exists but hasn't been updated
const location: FeedSource = {
id: "location",
...noActions,
async fetchContext() {
// Simulate no location available
return {}
return null
},
}

View File

@@ -1,54 +1,60 @@
import type { ActionDefinition } from "./action"
import type { Context } from "./context"
import type { FeedItem } from "./feed"
/**
* Unified interface for sources that provide context and/or feed items.
* Unified interface for sources that provide context, feed items, and actions.
*
* Sources form a dependency graph - a source declares which other sources
* 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
* Source IDs use reverse domain notation. Built-in sources use `aris.<name>`,
* third parties use their own domain (e.g., `com.spotify`).
*
* Every method maps to a protocol operation for remote source support:
* - `id`, `dependencies` → source/describe
* - `listActions()` → source/listActions
* - `executeAction()` → source/executeAction
* - `fetchContext()` → source/fetchContext
* - `fetchItems()` → source/fetchItems
* - `onContextUpdate()` → source/contextUpdated (notification)
* - `onItemsUpdate()` → source/itemsUpdated (notification)
*
* @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)
* },
* id: "aris.location",
* async listActions() { return { "update-location": { id: "update-location" } } },
* async executeAction(actionId) { throw new UnknownActionError(actionId) },
* async fetchContext() { ... },
* }
* ```
*/
export interface FeedSource<TItem extends FeedItem = FeedItem> {
/** Unique identifier for this source */
/** Unique identifier for this source in reverse-domain format */
readonly id: string
/** IDs of sources this source depends on */
readonly dependencies?: readonly string[]
/**
* List actions this source supports. Empty record if none.
* Maps to: source/listActions
*/
listActions(): Promise<Record<string, ActionDefinition>>
/**
* Execute an action by ID. Throws on unknown action or invalid input.
* Maps to: source/executeAction
*/
executeAction(actionId: string, params: unknown): Promise<unknown>
/**
* Subscribe to reactive context updates.
* Called when the source can push context changes proactively.
* Returns cleanup function.
* Maps to: source/contextUpdated (notification, source → host)
*/
onContextUpdate?(
callback: (update: Partial<Context>) => void,
@@ -58,19 +64,23 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
/**
* Fetch context on-demand.
* Called during manual refresh or initial load.
* Return null if this source cannot provide context.
* Maps to: source/fetchContext
*/
fetchContext?(context: Context): Promise<Partial<Context>>
fetchContext(context: Context): Promise<Partial<Context> | null>
/**
* Subscribe to reactive feed item updates.
* Called when the source can push item changes proactively.
* Returns cleanup function.
* Maps to: source/itemsUpdated (notification, source → host)
*/
onItemsUpdate?(callback: (items: TItem[]) => void, getContext: () => Context): () => void
/**
* Fetch feed items on-demand.
* Called during manual refresh or when dependencies update.
* Maps to: source/fetchItems
*/
fetchItems?(context: Context): Promise<TItem[]>
}

View File

@@ -2,26 +2,45 @@
export type { Context, ContextKey } from "./context"
export { contextKey, contextValue } from "./context"
// Actions
export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action"
// Feed
export type { FeedItem } from "./feed"
// Feed Source
export type { FeedSource } from "./feed-source"
// Feed Engine
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine"
// =============================================================================
// DEPRECATED - Use FeedSource + FeedEngine instead
// =============================================================================
// Data Source (deprecated - use FeedSource)
export type { DataSource } from "./data-source"
// Context Provider
// Context Provider (deprecated - use FeedSource)
export type { ContextProvider } from "./context-provider"
// Context Bridge
// Context Bridge (deprecated - use FeedEngine)
export type { ProviderError, RefreshResult } from "./context-bridge"
export { ContextBridge } from "./context-bridge"
// Reconciler
export type { ReconcileResult, ReconcilerConfig, SourceError } from "./reconciler"
// Reconciler (deprecated - use FeedEngine)
export type {
ReconcileResult,
ReconcilerConfig,
SourceError as ReconcilerSourceError,
} from "./reconciler"
export { Reconciler } from "./reconciler"
// Feed Controller
export type { FeedControllerConfig, FeedSubscriber } from "./feed-controller"
// Feed Controller (deprecated - use FeedEngine)
export type {
FeedControllerConfig,
FeedSubscriber as FeedControllerSubscriber,
} from "./feed-controller"
export { FeedController } from "./feed-controller"

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

@@ -26,14 +26,18 @@ const createMockContext = (location?: { lat: number; lng: number }): Context =>
describe("WeatherKitDataSource", () => {
test("returns empty array when location is missing", async () => {
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
const dataSource = new WeatherKitDataSource({
credentials: mockCredentials,
})
const items = await dataSource.query(createMockContext())
expect(items).toEqual([])
})
test("type is weather-current", () => {
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
const dataSource = new WeatherKitDataSource({
credentials: mockCredentials,
})
expect(dataSource.type).toBe(WeatherFeedItemType.current)
})
@@ -100,7 +104,9 @@ describe("WeatherKitDataSource with fixture", () => {
})
test("default limits are applied", () => {
const dataSource = new WeatherKitDataSource({ credentials: mockCredentials })
const dataSource = new WeatherKitDataSource({
credentials: mockCredentials,
})
expect(dataSource["hourlyLimit"]).toBe(12)
expect(dataSource["dailyLimit"]).toBe(7)
@@ -163,8 +169,12 @@ describe("query() with mocked client", () => {
const dataSource = new WeatherKitDataSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const metricItems = await dataSource.query(context, { units: Units.metric })
const imperialItems = await dataSource.query(context, { units: Units.imperial })
const metricItems = await dataSource.query(context, {
units: Units.metric,
})
const imperialItems = await dataSource.query(context, {
units: Units.imperial,
})
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.current)
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.current)

View File

@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:all-day-001@test
DTSTART;VALUE=DATE:20260115
DTEND;VALUE=DATE:20260116
SUMMARY:Company Holiday
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:cancelled-001@test
DTSTART:20260115T120000Z
DTEND:20260115T130000Z
SUMMARY:Cancelled Meeting
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,10 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:minimal-001@test
DTSTART:20260115T180000Z
DTEND:20260115T190000Z
SUMMARY:Quick Chat
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,20 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:recurring-001@test
DTSTART:20260115T090000Z
DTEND:20260115T093000Z
SUMMARY:Weekly Sync
RRULE:FREQ=WEEKLY;COUNT=4
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:recurring-001@test
RECURRENCE-ID:20260122T090000Z
DTSTART:20260122T100000Z
DTEND:20260122T103000Z
SUMMARY:Weekly Sync (moved)
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,26 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:single-event-001@test
DTSTART:20260115T140000Z
DTEND:20260115T150000Z
SUMMARY:Team Standup
LOCATION:Conference Room A
DESCRIPTION:Daily standup meeting
STATUS:CONFIRMED
URL:https://example.com/meeting/123
ORGANIZER;CN=Alice Smith:mailto:alice@example.com
ATTENDEE;CN=Bob Jones;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:bob@example.com
ATTENDEE;CN=Carol White;ROLE=OPT-PARTICIPANT;PARTSTAT=TENTATIVE:mailto:carol@example.com
BEGIN:VALARM
TRIGGER:-PT15M
ACTION:DISPLAY
DESCRIPTION:Reminder
END:VALARM
BEGIN:VALARM
TRIGGER:-PT5M
ACTION:AUDIO
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,15 @@
{
"name": "@aris/source-apple-calendar",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ."
},
"dependencies": {
"@aris/core": "workspace:*",
"ical.js": "^2.1.0",
"tsdav": "^2.1.7"
}
}

View File

@@ -0,0 +1,24 @@
import type { ContextKey } from "@aris/core"
import { contextKey } from "@aris/core"
import type { CalendarEventData } from "./types.ts"
/**
* Calendar context for downstream sources.
*
* Provides a snapshot of the user's upcoming events so other sources
* can adapt (e.g. a commute source checking if there's a meeting soon).
*/
export interface CalendarContext {
/** Events happening right now */
inProgress: CalendarEventData[]
/** Next upcoming event, if any */
nextEvent: CalendarEventData | null
/** Whether the user has any events today */
hasTodayEvents: boolean
/** Total number of events today */
todayEventCount: number
}
export const CalendarKey: ContextKey<CalendarContext> = contextKey("calendar")

View File

@@ -0,0 +1,473 @@
import type { Context } from "@aris/core"
import { contextValue } from "@aris/core"
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
import type {
CalendarCredentialProvider,
CalendarCredentials,
CalendarDAVCalendar,
CalendarDAVClient,
CalendarDAVObject,
CalendarEventData,
} from "./types.ts"
import { CalendarKey } from "./calendar-context.ts"
import { CalendarSource, computePriority } from "./calendar-source.ts"
function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
}
function createContext(time: Date): Context {
return { time }
}
const mockCredentials: CalendarCredentials = {
accessToken: "mock-access-token",
refreshToken: "mock-refresh-token",
expiresAt: Date.now() + 3600000,
tokenUrl: "https://appleid.apple.com/auth/token",
clientId: "com.example.aris",
clientSecret: "mock-secret",
}
class NullCredentialProvider implements CalendarCredentialProvider {
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
return null
}
}
class MockCredentialProvider implements CalendarCredentialProvider {
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
return mockCredentials
}
}
class MockDAVClient implements CalendarDAVClient {
credentials: Record<string, unknown> = {}
fetchCalendarsCallCount = 0
private calendars: CalendarDAVCalendar[]
private objectsByCalendarUrl: Record<string, CalendarDAVObject[]>
constructor(
calendars: CalendarDAVCalendar[],
objectsByCalendarUrl: Record<string, CalendarDAVObject[]>,
) {
this.calendars = calendars
this.objectsByCalendarUrl = objectsByCalendarUrl
}
async login(): Promise<void> {}
async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
this.fetchCalendarsCallCount++
return this.calendars
}
async fetchCalendarObjects(params: {
calendar: CalendarDAVCalendar
timeRange: { start: string; end: string }
}): Promise<CalendarDAVObject[]> {
return this.objectsByCalendarUrl[params.calendar.url] ?? []
}
}
describe("CalendarSource", () => {
test("has correct id", () => {
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
expect(source.id).toBe("aris.apple-calendar")
})
test("returns empty array when credentials are null", async () => {
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toEqual([])
})
test("returns empty array when no calendars exist", async () => {
const client = new MockDAVClient([], {})
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toEqual([])
})
test("returns feed items from a single calendar", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1)
expect(items[0]!.type).toBe("calendar-event")
expect(items[0]!.id).toBe("calendar-event-single-event-001@test")
expect(items[0]!.data.title).toBe("Team Standup")
expect(items[0]!.data.location).toBe("Conference Room A")
expect(items[0]!.data.calendarName).toBe("Work")
expect(items[0]!.data.attendees).toHaveLength(2)
expect(items[0]!.data.alarms).toHaveLength(2)
})
test("returns feed items from multiple calendars", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
"/cal/personal": [
{
url: "/cal/personal/event2.ics",
data: loadFixture("all-day-event.ics"),
},
],
}
const client = new MockDAVClient(
[
{ url: "/cal/work", displayName: "Work" },
{ url: "/cal/personal", displayName: "Personal" },
],
objects,
)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(2)
const standup = items.find((i) => i.data.title === "Team Standup")
const holiday = items.find((i) => i.data.title === "Company Holiday")
expect(standup).toBeDefined()
expect(standup!.data.calendarName).toBe("Work")
expect(holiday).toBeDefined()
expect(holiday!.data.calendarName).toBe("Personal")
expect(holiday!.data.isAllDay).toBe(true)
})
test("skips objects with non-string data", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/bad.ics", data: 12345 },
{ url: "/cal/work/empty.ics" },
],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1)
expect(items[0]!.data.title).toBe("Team Standup")
})
test("uses context time as feed item timestamp", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const now = new Date("2026-01-15T12:00:00Z")
const items = await source.fetchItems(createContext(now))
expect(items[0]!.timestamp).toEqual(now)
})
test("assigns priority based on event proximity", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
// 2 hours before the event at 14:00
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
const standup = items.find((i) => i.data.title === "Team Standup")
const holiday = items.find((i) => i.data.title === "Company Holiday")
expect(standup!.priority).toBe(0.7) // within 2 hours
expect(holiday!.priority).toBe(0.3) // all-day
})
test("handles calendar with non-string displayName", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/weird": [
{
url: "/cal/weird/event1.ics",
data: loadFixture("minimal-event.ics"),
},
],
}
const client = new MockDAVClient(
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
objects,
)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items[0]!.data.calendarName).toBeNull()
})
test("handles recurring events with exceptions", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [
{
url: "/cal/work/recurring.ics",
data: loadFixture("recurring-event.ics"),
},
],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
expect(items).toHaveLength(2)
const base = items.find((i) => i.data.title === "Weekly Sync")
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
expect(base).toBeDefined()
expect(base!.data.recurrenceId).toBeNull()
expect(exception).toBeDefined()
expect(exception!.data.recurrenceId).not.toBeNull()
expect(exception!.id).toContain("-")
})
test("caches events within the same refresh cycle", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const context = createContext(new Date("2026-01-15T12:00:00Z"))
await source.fetchContext(context)
await source.fetchItems(context)
// Same context.time reference — fetchEvents should only hit the client once
expect(client.fetchCalendarsCallCount).toBe(1)
})
test("refetches events for a different context time", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
// Different context.time references — should fetch twice
expect(client.fetchCalendarsCallCount).toBe(2)
})
})
describe("CalendarSource.fetchContext", () => {
test("returns empty context when credentials are null", async () => {
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey)
expect(calendar).toBeDefined()
expect(calendar!.inProgress).toEqual([])
expect(calendar!.nextEvent).toBeNull()
expect(calendar!.hasTodayEvents).toBe(false)
expect(calendar!.todayEventCount).toBe(0)
})
test("identifies in-progress events", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
// 14:30 is during the 14:00-15:00 event
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey)
expect(calendar!.inProgress).toHaveLength(1)
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
})
test("identifies next upcoming event", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
// 12:00 is before the 14:00 event
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey)
expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).not.toBeNull()
expect(calendar!.nextEvent!.title).toBe("Team Standup")
})
test("excludes all-day events from inProgress and nextEvent", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey)
expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).toBeNull()
expect(calendar!.hasTodayEvents).toBe(true)
expect(calendar!.todayEventCount).toBe(1)
})
test("counts all events including all-day in todayEventCount", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey)
expect(calendar!.todayEventCount).toBe(2)
expect(calendar!.hasTodayEvents).toBe(true)
})
})
describe("computePriority", () => {
const now = new Date("2026-01-15T12:00:00Z")
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
return {
uid: "test-uid",
title: "Test",
startDate: new Date("2026-01-15T14:00:00Z"),
endDate: new Date("2026-01-15T15:00:00Z"),
isAllDay: false,
location: null,
description: null,
calendarName: null,
status: null,
url: null,
organizer: null,
attendees: [],
alarms: [],
recurrenceId: null,
...overrides,
}
}
test("all-day events get priority 0.3", () => {
const event = makeEvent({ isAllDay: true })
expect(computePriority(event, now)).toBe(0.3)
})
test("events starting within 30 minutes get priority 0.9", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T12:20:00Z"),
})
expect(computePriority(event, now)).toBe(0.9)
})
test("events starting exactly at 30 minutes get priority 0.9", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T12:30:00Z"),
})
expect(computePriority(event, now)).toBe(0.9)
})
test("events starting within 2 hours get priority 0.7", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T13:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.7)
})
test("events later today get priority 0.5", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T20:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.5)
})
test("in-progress events get priority 0.8", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T11:00:00Z"),
endDate: new Date("2026-01-15T13:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.8)
})
test("fully past events get priority 0.2", () => {
const event = makeEvent({
startDate: new Date("2026-01-15T09:00:00Z"),
endDate: new Date("2026-01-15T10:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.2)
})
test("events on future days get priority 0.2", () => {
const event = makeEvent({
startDate: new Date("2026-01-16T10:00:00Z"),
})
expect(computePriority(event, now)).toBe(0.2)
})
test("priority boundaries are correct", () => {
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
const event31min = makeEvent({
startDate: new Date("2026-01-15T12:31:00Z"),
})
expect(computePriority(event31min, now)).toBe(0.7)
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
const event2h1m = makeEvent({
startDate: new Date("2026-01-15T14:01:00Z"),
})
expect(computePriority(event2h1m, now)).toBe(0.5)
})
})

View File

@@ -0,0 +1,251 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { UnknownActionError } from "@aris/core"
import { DAVClient } from "tsdav"
import type {
CalendarCredentialProvider,
CalendarCredentials,
CalendarDAVClient,
CalendarEventData,
CalendarFeedItem,
} from "./types.ts"
export interface CalendarSourceOptions {
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
lookAheadDays?: number
/** Optional DAVClient instance for testing. Uses tsdav DAVClient by default. */
davClient?: CalendarDAVClient
}
import { CalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts"
const ICLOUD_CALDAV_URL = "https://caldav.icloud.com"
const DEFAULT_LOOK_AHEAD_DAYS = 0
/**
* A FeedSource that fetches Apple Calendar events via CalDAV.
*
* Credentials are provided by an injected CalendarCredentialProvider.
* The server is responsible for managing OAuth tokens and storage.
*
* @example
* ```ts
* const source = new CalendarSource(credentialProvider, "user-123")
* const engine = new FeedEngine()
* engine.register(source)
* ```
*/
export class CalendarSource implements FeedSource<CalendarFeedItem> {
readonly id = "aris.apple-calendar"
private readonly credentialProvider: CalendarCredentialProvider
private readonly userId: string
private readonly lookAheadDays: number
private readonly injectedClient: CalendarDAVClient | null
private davClient: CalendarDAVClient | null = null
private lastAccessToken: string | null = null
private cachedEvents: { time: Date; events: CalendarEventData[] } | null = null
constructor(
credentialProvider: CalendarCredentialProvider,
userId: string,
options?: CalendarSourceOptions,
) {
this.credentialProvider = credentialProvider
this.userId = userId
this.lookAheadDays = options?.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
this.injectedClient = options?.davClient ?? null
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
}
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<Partial<Context> | null> {
const events = await this.fetchEvents(context)
if (events.length === 0) {
return {
[CalendarKey]: {
inProgress: [],
nextEvent: null,
hasTodayEvents: false,
todayEventCount: 0,
},
}
}
const now = context.time
const inProgress = events.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
const upcoming = events
.filter((e) => !e.isAllDay && e.startDate > now)
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
const calendarContext: CalendarContext = {
inProgress,
nextEvent: upcoming[0] ?? null,
hasTodayEvents: events.length > 0,
todayEventCount: events.length,
}
return { [CalendarKey]: calendarContext }
}
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
const now = context.time
const events = await this.fetchEvents(context)
return events.map((event) => createFeedItem(event, now))
}
private async fetchEvents(context: Context): Promise<CalendarEventData[]> {
if (this.cachedEvents && this.cachedEvents.time === context.time) {
return this.cachedEvents.events
}
const credentials = await this.credentialProvider.fetchCredentials(this.userId)
if (!credentials) {
return []
}
const client = await this.connectClient(credentials)
const calendars = await client.fetchCalendars()
const { start, end } = computeTimeRange(context.time, this.lookAheadDays)
const results = await Promise.allSettled(
calendars.map(async (calendar) => {
const objects = await client.fetchCalendarObjects({
calendar,
timeRange: {
start: start.toISOString(),
end: end.toISOString(),
},
})
// tsdav types displayName as string | Record<string, unknown> | undefined
// because the XML parser can return an object for some responses
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
return { objects, calendarName }
}),
)
const allEvents: CalendarEventData[] = []
for (const result of results) {
if (result.status !== "fulfilled") continue
const { objects, calendarName } = result.value
for (const obj of objects) {
if (typeof obj.data !== "string") continue
const events = parseICalEvents(obj.data, calendarName)
for (const event of events) {
allEvents.push(event)
}
}
}
this.cachedEvents = { time: context.time, events: allEvents }
return allEvents
}
/**
* Returns a ready-to-use DAVClient. Creates and logs in a new client
* on first call; reuses the existing one on subsequent calls, updating
* credentials if the access token has changed.
*/
private async connectClient(credentials: CalendarCredentials): Promise<CalendarDAVClient> {
if (this.injectedClient) {
return this.injectedClient
}
const davCredentials = {
tokenUrl: credentials.tokenUrl,
refreshToken: credentials.refreshToken,
accessToken: credentials.accessToken,
expiration: credentials.expiresAt,
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
}
if (!this.davClient) {
this.davClient = new DAVClient({
serverUrl: ICLOUD_CALDAV_URL,
credentials: davCredentials,
authMethod: "Oauth",
defaultAccountType: "caldav",
})
await this.davClient.login()
this.lastAccessToken = credentials.accessToken
return this.davClient
}
if (credentials.accessToken !== this.lastAccessToken) {
this.davClient.credentials = davCredentials
this.lastAccessToken = credentials.accessToken
}
return this.davClient
}
}
function computeTimeRange(now: Date, lookAheadDays: number): { start: Date; end: Date } {
const start = new Date(now)
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start)
end.setUTCDate(end.getUTCDate() + 1 + lookAheadDays)
return { start, end }
}
export function computePriority(event: CalendarEventData, now: Date): number {
if (event.isAllDay) {
return 0.3
}
const msUntilStart = event.startDate.getTime() - now.getTime()
// Event already started
if (msUntilStart < 0) {
const isInProgress = now.getTime() < event.endDate.getTime()
// Currently happening events are high priority; fully past events are low
return isInProgress ? 0.8 : 0.2
}
// Starting within 30 minutes
if (msUntilStart <= 30 * 60 * 1000) {
return 0.9
}
// Starting within 2 hours
if (msUntilStart <= 2 * 60 * 60 * 1000) {
return 0.7
}
// Later today (within 24 hours from start of day)
const startOfDay = new Date(now)
startOfDay.setUTCHours(0, 0, 0, 0)
const endOfDay = new Date(startOfDay)
endOfDay.setUTCDate(endOfDay.getUTCDate() + 1)
if (event.startDate.getTime() < endOfDay.getTime()) {
return 0.5
}
// Future days
return 0.2
}
function createFeedItem(event: CalendarEventData, now: Date): CalendarFeedItem {
return {
id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
type: "calendar-event",
priority: computePriority(event, now),
timestamp: now,
data: event,
}
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { parseICalEvents } from "./ical-parser.ts"
function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
}
describe("parseICalEvents", () => {
test("parses a full event with all fields", () => {
const events = parseICalEvents(loadFixture("single-event.ics"), "Work")
expect(events).toHaveLength(1)
const event = events[0]!
expect(event.uid).toBe("single-event-001@test")
expect(event.title).toBe("Team Standup")
expect(event.startDate).toEqual(new Date("2026-01-15T14:00:00Z"))
expect(event.endDate).toEqual(new Date("2026-01-15T15:00:00Z"))
expect(event.isAllDay).toBe(false)
expect(event.location).toBe("Conference Room A")
expect(event.description).toBe("Daily standup meeting")
expect(event.calendarName).toBe("Work")
expect(event.status).toBe("confirmed")
expect(event.url).toBe("https://example.com/meeting/123")
expect(event.organizer).toBe("Alice Smith")
expect(event.recurrenceId).toBeNull()
expect(event.attendees).toHaveLength(2)
expect(event.attendees[0]).toEqual({
name: "Bob Jones",
email: "bob@example.com",
role: "required",
status: "accepted",
})
expect(event.attendees[1]).toEqual({
name: "Carol White",
email: "carol@example.com",
role: "optional",
status: "tentative",
})
expect(event.alarms).toHaveLength(2)
expect(event.alarms[0]).toEqual({ trigger: "-PT15M", action: "DISPLAY" })
expect(event.alarms[1]).toEqual({ trigger: "-PT5M", action: "AUDIO" })
})
test("parses an all-day event with optional fields as null", () => {
const events = parseICalEvents(loadFixture("all-day-event.ics"), null)
expect(events).toHaveLength(1)
const event = events[0]!
expect(event.isAllDay).toBe(true)
expect(event.title).toBe("Company Holiday")
expect(event.calendarName).toBeNull()
expect(event.location).toBeNull()
expect(event.description).toBeNull()
expect(event.url).toBeNull()
expect(event.organizer).toBeNull()
expect(event.attendees).toEqual([])
expect(event.alarms).toEqual([])
})
test("parses recurring event with exception", () => {
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
expect(events).toHaveLength(2)
expect(events[0]!.uid).toBe("recurring-001@test")
expect(events[1]!.uid).toBe("recurring-001@test")
const base = events.find((e) => e.title === "Weekly Sync")
expect(base).toBeDefined()
expect(base!.recurrenceId).toBeNull()
const exception = events.find((e) => e.title === "Weekly Sync (moved)")
expect(exception).toBeDefined()
expect(exception!.recurrenceId).not.toBeNull()
})
test("parses minimal event with defaults", () => {
const events = parseICalEvents(loadFixture("minimal-event.ics"), null)
expect(events).toHaveLength(1)
const event = events[0]!
expect(event.uid).toBe("minimal-001@test")
expect(event.title).toBe("Quick Chat")
expect(event.startDate).toEqual(new Date("2026-01-15T18:00:00Z"))
expect(event.endDate).toEqual(new Date("2026-01-15T19:00:00Z"))
expect(event.location).toBeNull()
expect(event.description).toBeNull()
expect(event.status).toBeNull()
expect(event.url).toBeNull()
expect(event.organizer).toBeNull()
expect(event.attendees).toEqual([])
expect(event.alarms).toEqual([])
expect(event.recurrenceId).toBeNull()
})
test("parses cancelled status", () => {
const events = parseICalEvents(loadFixture("cancelled-event.ics"), null)
expect(events[0]!.status).toBe("cancelled")
})
})

View File

@@ -0,0 +1,150 @@
import ICAL from "ical.js"
import {
AttendeeRole,
AttendeeStatus,
CalendarEventStatus,
type CalendarAlarm,
type CalendarAttendee,
type CalendarEventData,
} from "./types.ts"
/**
* Parses a raw iCalendar string and extracts all VEVENT components
* into CalendarEventData objects.
*
* @param icsData - Raw iCalendar string from a CalDAV response
* @param calendarName - Display name of the calendar this event belongs to
*/
export function parseICalEvents(icsData: string, calendarName: string | null): CalendarEventData[] {
const jcal = ICAL.parse(icsData)
const comp = new ICAL.Component(jcal)
const vevents = comp.getAllSubcomponents("vevent")
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
parseVEvent(vevent, calendarName),
)
}
function parseVEvent(
vevent: InstanceType<typeof ICAL.Component>,
calendarName: string | null,
): CalendarEventData {
const event = new ICAL.Event(vevent)
return {
uid: event.uid ?? "",
title: event.summary ?? "",
startDate: event.startDate?.toJSDate() ?? new Date(0),
endDate: event.endDate?.toJSDate() ?? new Date(0),
isAllDay: event.startDate?.isDate ?? false,
location: event.location ?? null,
description: event.description ?? null,
calendarName,
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
alarms: parseAlarms(vevent),
recurrenceId: event.recurrenceId ? event.recurrenceId.toString() : null,
}
}
function parseStatus(raw: string | null): CalendarEventStatus | null {
if (!raw) return null
switch (raw.toLowerCase()) {
case "confirmed":
return CalendarEventStatus.Confirmed
case "tentative":
return CalendarEventStatus.Tentative
case "cancelled":
return CalendarEventStatus.Cancelled
default:
return null
}
}
function parseOrganizer(
value: string | null,
vevent: InstanceType<typeof ICAL.Component>,
): string | null {
if (!value) return null
// Try CN parameter first
const prop = vevent.getFirstProperty("organizer")
if (prop) {
const cn = prop.getParameter("cn") as string | undefined
if (cn) return cn
}
// Fall back to mailto: value
return value.replace(/^mailto:/i, "")
}
function parseAttendees(properties: unknown[]): CalendarAttendee[] {
if (properties.length === 0) return []
return properties.map((prop) => {
const p = prop as InstanceType<typeof ICAL.Property>
const value = asStringOrNull(p.getFirstValue())
const cn = asStringOrNull(p.getParameter("cn"))
const role = asStringOrNull(p.getParameter("role"))
const partstat = asStringOrNull(p.getParameter("partstat"))
return {
name: cn,
email: value ? value.replace(/^mailto:/i, "") : null,
role: parseAttendeeRole(role),
status: parseAttendeeStatus(partstat),
}
})
}
function parseAttendeeRole(raw: string | null): AttendeeRole | null {
if (!raw) return null
switch (raw.toUpperCase()) {
case "CHAIR":
return AttendeeRole.Chair
case "REQ-PARTICIPANT":
return AttendeeRole.Required
case "OPT-PARTICIPANT":
return AttendeeRole.Optional
default:
return null
}
}
function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
if (!raw) return null
switch (raw.toUpperCase()) {
case "ACCEPTED":
return AttendeeStatus.Accepted
case "DECLINED":
return AttendeeStatus.Declined
case "TENTATIVE":
return AttendeeStatus.Tentative
case "NEEDS-ACTION":
return AttendeeStatus.NeedsAction
default:
return null
}
}
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] {
const valarms = vevent.getAllSubcomponents("valarm")
if (!valarms || valarms.length === 0) return []
return valarms.map((valarm: InstanceType<typeof ICAL.Component>) => {
const trigger = valarm.getFirstPropertyValue("trigger")
const action = asStringOrNull(valarm.getFirstPropertyValue("action"))
return {
trigger: trigger ? trigger.toString() : "",
action: action ?? "DISPLAY",
}
})
}
function asStringOrNull(value: unknown): string | null {
return typeof value === "string" ? value : null
}

View File

@@ -0,0 +1,16 @@
export { CalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalendarSource, type CalendarSourceOptions } from "./calendar-source.ts"
export {
CalendarEventStatus,
AttendeeRole,
AttendeeStatus,
type CalendarCredentials,
type CalendarCredentialProvider,
type CalendarDAVClient,
type CalendarDAVCalendar,
type CalendarDAVObject,
type CalendarAttendee,
type CalendarAlarm,
type CalendarEventData,
type CalendarFeedItem,
} from "./types.ts"

View File

@@ -0,0 +1,101 @@
import type { FeedItem } from "@aris/core"
// -- Credential provider --
export interface CalendarCredentials {
accessToken: string
refreshToken: string
/** Unix timestamp in milliseconds when the access token expires */
expiresAt: number
tokenUrl: string
clientId: string
clientSecret: string
}
export interface CalendarCredentialProvider {
fetchCredentials(userId: string): Promise<CalendarCredentials | null>
}
// -- Feed item types --
export const CalendarEventStatus = {
Confirmed: "confirmed",
Tentative: "tentative",
Cancelled: "cancelled",
} as const
export type CalendarEventStatus = (typeof CalendarEventStatus)[keyof typeof CalendarEventStatus]
export const AttendeeRole = {
Chair: "chair",
Required: "required",
Optional: "optional",
} as const
export type AttendeeRole = (typeof AttendeeRole)[keyof typeof AttendeeRole]
export const AttendeeStatus = {
Accepted: "accepted",
Declined: "declined",
Tentative: "tentative",
NeedsAction: "needs-action",
} as const
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
export interface CalendarAttendee {
name: string | null
email: string | null
role: AttendeeRole | null
status: AttendeeStatus | null
}
export interface CalendarAlarm {
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
trigger: string
/** e.g. "DISPLAY", "AUDIO" */
action: string
}
export interface CalendarEventData extends Record<string, unknown> {
uid: string
title: string
startDate: Date
endDate: Date
isAllDay: boolean
location: string | null
description: string | null
calendarName: string | null
status: CalendarEventStatus | null
url: string | null
organizer: string | null
attendees: CalendarAttendee[]
alarms: CalendarAlarm[]
recurrenceId: string | null
}
export type CalendarFeedItem = FeedItem<"calendar-event", CalendarEventData>
// -- DAV client interface --
export interface CalendarDAVObject {
data?: unknown
etag?: string
url: string
}
export interface CalendarDAVCalendar {
displayName?: string | Record<string, unknown>
url: string
}
/** Subset of DAVClient used by CalendarSource. */
export interface CalendarDAVClient {
login(): Promise<void>
fetchCalendars(): Promise<CalendarDAVCalendar[]>
fetchCalendarObjects(params: {
calendar: CalendarDAVCalendar
timeRange: { start: string; end: string }
}): Promise<CalendarDAVObject[]>
credentials: Record<string, unknown>
}

View File

@@ -0,0 +1,72 @@
{
"kind": "calendar#events",
"summary": "primary",
"items": [
{
"id": "evt-ongoing",
"status": "confirmed",
"htmlLink": "https://calendar.google.com/event?eid=evt-ongoing",
"summary": "Team Standup",
"description": "Daily standup meeting",
"location": "Room 3A",
"start": {
"dateTime": "2026-01-20T09:30:00Z"
},
"end": {
"dateTime": "2026-01-20T10:15:00Z"
}
},
{
"id": "evt-soon",
"status": "confirmed",
"htmlLink": "https://calendar.google.com/event?eid=evt-soon",
"summary": "1:1 with Manager",
"start": {
"dateTime": "2026-01-20T10:10:00Z"
},
"end": {
"dateTime": "2026-01-20T10:40:00Z"
}
},
{
"id": "evt-later",
"status": "confirmed",
"htmlLink": "https://calendar.google.com/event?eid=evt-later",
"summary": "Design Review",
"description": "Review new dashboard designs",
"location": "Conference Room B",
"start": {
"dateTime": "2026-01-20T14:00:00Z"
},
"end": {
"dateTime": "2026-01-20T15:00:00Z"
}
},
{
"id": "evt-tentative",
"status": "tentative",
"htmlLink": "https://calendar.google.com/event?eid=evt-tentative",
"summary": "Lunch with Alex",
"location": "Cafe Nero",
"start": {
"dateTime": "2026-01-20T12:00:00Z"
},
"end": {
"dateTime": "2026-01-20T13:00:00Z"
}
},
{
"id": "evt-allday",
"status": "confirmed",
"htmlLink": "https://calendar.google.com/event?eid=evt-allday",
"summary": "Company Holiday",
"description": "Office closed",
"start": {
"date": "2026-01-20"
},
"end": {
"date": "2026-01-21"
}
}
]
}

View File

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

View File

@@ -0,0 +1,13 @@
import type { ContextKey } from "@aris/core"
import { contextKey } from "@aris/core"
export interface NextEvent {
title: string
startTime: Date
endTime: Date
minutesUntilStart: number
location: string | null
}
export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")

View File

@@ -0,0 +1,22 @@
import type { FeedItem } from "@aris/core"
import type { CalendarEventData } from "./types"
export const CalendarFeedItemType = {
event: "calendar-event",
allDay: "calendar-all-day",
} as const
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
export interface CalendarEventFeedItem extends FeedItem<
typeof CalendarFeedItemType.event,
CalendarEventData
> {}
export interface CalendarAllDayFeedItem extends FeedItem<
typeof CalendarFeedItemType.allDay,
CalendarEventData
> {}
export type CalendarFeedItem = CalendarEventFeedItem | CalendarAllDayFeedItem

View File

@@ -0,0 +1,122 @@
// Google Calendar REST API v3 client
// https://developers.google.com/calendar/api/v3/reference/events/list
import { type } from "arktype"
import type {
ApiCalendarEvent,
GoogleCalendarClient,
GoogleOAuthProvider,
ListEventsOptions,
} from "./types"
import { EventStatus } from "./types"
const eventStatusSchema = type.enumerated(
EventStatus.Confirmed,
EventStatus.Tentative,
EventStatus.Cancelled,
)
const eventDateTimeSchema = type({
"dateTime?": "string",
"date?": "string",
"timeZone?": "string",
})
const eventSchema = type({
id: "string",
status: eventStatusSchema,
htmlLink: "string",
"summary?": "string",
"description?": "string",
"location?": "string",
start: eventDateTimeSchema,
end: eventDateTimeSchema,
})
const calendarListEntrySchema = type({
id: "string",
})
const calendarListResponseSchema = type({
"items?": calendarListEntrySchema.array(),
"nextPageToken?": "string",
})
const eventsResponseSchema = type({
"items?": eventSchema.array(),
"nextPageToken?": "string",
})
export class DefaultGoogleCalendarClient implements GoogleCalendarClient {
private static readonly API_BASE = "https://www.googleapis.com/calendar/v3"
private readonly oauthProvider: GoogleOAuthProvider
constructor(oauthProvider: GoogleOAuthProvider) {
this.oauthProvider = oauthProvider
}
async listCalendarIds(): Promise<string[]> {
const url = `${DefaultGoogleCalendarClient.API_BASE}/users/me/calendarList?fields=items(id)`
const json = await this.request(url)
const result = calendarListResponseSchema(json)
if (result instanceof type.errors) {
throw new Error(`Google Calendar API response validation failed: ${result.summary}`)
}
if (!result.items) {
return []
}
return result.items.map((entry) => entry.id)
}
async listEvents(options: ListEventsOptions): Promise<ApiCalendarEvent[]> {
const url = new URL(
`${DefaultGoogleCalendarClient.API_BASE}/calendars/${encodeURIComponent(options.calendarId)}/events`,
)
url.searchParams.set("timeMin", options.timeMin.toISOString())
url.searchParams.set("timeMax", options.timeMax.toISOString())
url.searchParams.set("singleEvents", "true")
url.searchParams.set("orderBy", "startTime")
const json = await this.request(url.toString())
const result = eventsResponseSchema(json)
if (result instanceof type.errors) {
throw new Error(`Google Calendar API response validation failed: ${result.summary}`)
}
if (!result.items) {
return []
}
return result.items
}
/** Authenticated GET with auto token refresh on 401. */
private async request(url: string): Promise<unknown> {
const token = await this.oauthProvider.fetchAccessToken()
let response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
if (response.status === 401) {
const newToken = await this.oauthProvider.refresh()
response = await fetch(url, {
headers: { Authorization: `Bearer ${newToken}` },
})
}
if (!response.ok) {
const body = await response.text()
throw new Error(
`Google Calendar API error: ${response.status} ${response.statusText}: ${body}`,
)
}
return response.json()
}
}

View File

@@ -0,0 +1,296 @@
import { contextValue, type Context } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
import fixture from "../fixtures/events.json"
import { NextEventKey } from "./calendar-context"
import { CalendarFeedItemType } from "./feed-items"
import { GoogleCalendarSource } from "./google-calendar-source"
const NOW = new Date("2026-01-20T10:00:00Z")
function fixtureEvents(): ApiCalendarEvent[] {
return fixture.items as unknown as ApiCalendarEvent[]
}
function createMockClient(
eventsByCalendar: Record<string, ApiCalendarEvent[]>,
): GoogleCalendarClient {
return {
listCalendarIds: async () => Object.keys(eventsByCalendar),
listEvents: async (options: ListEventsOptions) => {
const events = eventsByCalendar[options.calendarId] ?? []
return events.filter((e) => {
const startRaw = e.start.dateTime ?? e.start.date ?? ""
const endRaw = e.end.dateTime ?? e.end.date ?? ""
return (
new Date(startRaw).getTime() < options.timeMax.getTime() &&
new Date(endRaw).getTime() > options.timeMin.getTime()
)
})
},
}
}
function defaultMockClient(): GoogleCalendarClient {
return createMockClient({ primary: fixtureEvents() })
}
function createContext(time?: Date): Context {
return { time: time ?? NOW }
}
describe("GoogleCalendarSource", () => {
describe("constructor", () => {
test("has correct id", () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
expect(source.id).toBe("aris.google-calendar")
})
})
describe("fetchItems", () => {
test("returns empty array when no events", async () => {
const source = new GoogleCalendarSource({
client: createMockClient({ primary: [] }),
})
const items = await source.fetchItems(createContext())
expect(items).toEqual([])
})
test("returns feed items for all events in window", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
expect(items.length).toBe(fixture.items.length)
})
test("assigns calendar-event type to timed events", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.event)
expect(timedItems.length).toBe(4)
})
test("assigns calendar-all-day type to all-day events", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.allDay)
expect(allDayItems.length).toBe(1)
})
test("ongoing events get highest priority (1.0)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
expect(ongoing).toBeDefined()
expect(ongoing!.priority).toBe(1.0)
})
test("upcoming events get higher priority when sooner", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const soon = items.find((i) => i.data.eventId === "evt-soon")
const later = items.find((i) => i.data.eventId === "evt-later")
expect(soon).toBeDefined()
expect(later).toBeDefined()
expect(soon!.priority).toBeGreaterThan(later!.priority)
})
test("all-day events get flat priority (0.4)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const allDay = items.find((i) => i.data.eventId === "evt-allday")
expect(allDay).toBeDefined()
expect(allDay!.priority).toBe(0.4)
})
test("generates unique IDs for each item", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ids = items.map((i) => i.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
test("sets timestamp from context.time", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
for (const item of items) {
expect(item.timestamp).toEqual(NOW)
}
})
test("respects lookaheadHours", async () => {
// Only 2 hours lookahead from 10:00 → events before 12:00
const source = new GoogleCalendarSource({
client: defaultMockClient(),
lookaheadHours: 2,
})
const items = await source.fetchItems(createContext())
// Should include: ongoing (09:30-10:15), soon (10:10-10:40), allday (00:00-next day)
// Should exclude: later (14:00), tentative lunch (12:00)
const eventIds = items.map((i) => i.data.eventId)
expect(eventIds).toContain("evt-ongoing")
expect(eventIds).toContain("evt-soon")
expect(eventIds).toContain("evt-allday")
expect(eventIds).not.toContain("evt-later")
expect(eventIds).not.toContain("evt-tentative")
})
test("defaults to all user calendars via listCalendarIds", async () => {
const workEvent: ApiCalendarEvent = {
id: "evt-work",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-work",
summary: "Work Meeting",
start: { dateTime: "2026-01-20T11:00:00Z" },
end: { dateTime: "2026-01-20T12:00:00Z" },
}
const client = createMockClient({
primary: fixtureEvents(),
"work@example.com": [workEvent],
})
// No calendarIds provided — should discover both calendars
const source = new GoogleCalendarSource({ client })
const items = await source.fetchItems(createContext())
const eventIds = items.map((i) => i.data.eventId)
expect(eventIds).toContain("evt-work")
expect(eventIds).toContain("evt-ongoing")
})
test("fetches from explicit calendar IDs", async () => {
const workEvent: ApiCalendarEvent = {
id: "evt-work",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-work",
summary: "Work Meeting",
start: { dateTime: "2026-01-20T11:00:00Z" },
end: { dateTime: "2026-01-20T12:00:00Z" },
}
const client = createMockClient({
primary: fixtureEvents(),
"work@example.com": [workEvent],
})
const source = new GoogleCalendarSource({
client,
calendarIds: ["primary", "work@example.com"],
})
const items = await source.fetchItems(createContext())
const eventIds = items.map((i) => i.data.eventId)
expect(eventIds).toContain("evt-work")
expect(eventIds).toContain("evt-ongoing")
})
})
describe("fetchContext", () => {
test("returns null when no events", async () => {
const source = new GoogleCalendarSource({
client: createMockClient({ primary: [] }),
})
const result = await source.fetchContext(createContext())
expect(result).toBeNull()
})
test("returns null when only all-day events", async () => {
const allDayOnly: ApiCalendarEvent[] = [
{
id: "evt-allday",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-allday",
summary: "Holiday",
start: { date: "2026-01-20" },
end: { date: "2026-01-21" },
},
]
const source = new GoogleCalendarSource({
client: createMockClient({ primary: allDayOnly }),
})
const result = await source.fetchContext(createContext())
expect(result).toBeNull()
})
test("returns next upcoming timed event (not ongoing)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const result = await source.fetchContext(createContext())
expect(result).not.toBeNull()
const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent).toBeDefined()
// evt-soon starts at 10:10, which is the nearest future timed event
expect(nextEvent!.title).toBe("1:1 with Manager")
expect(nextEvent!.minutesUntilStart).toBe(10)
expect(nextEvent!.location).toBeNull()
})
test("includes location when available", async () => {
const events: ApiCalendarEvent[] = [
{
id: "evt-loc",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-loc",
summary: "Offsite",
location: "123 Main St",
start: { dateTime: "2026-01-20T11:00:00Z" },
end: { dateTime: "2026-01-20T12:00:00Z" },
},
]
const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }),
})
const result = await source.fetchContext(createContext())
expect(result).not.toBeNull()
const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent).toBeDefined()
expect(nextEvent!.location).toBe("123 Main St")
})
test("skips ongoing events for next-event context", async () => {
const events: ApiCalendarEvent[] = [
{
id: "evt-now",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-now",
summary: "Current Meeting",
start: { dateTime: "2026-01-20T09:30:00Z" },
end: { dateTime: "2026-01-20T10:30:00Z" },
},
]
const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }),
})
const result = await source.fetchContext(createContext())
expect(result).toBeNull()
})
})
describe("priority ordering", () => {
test("ongoing > upcoming > all-day", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")!
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
expect(ongoing.priority).toBeGreaterThan(upcoming.priority)
expect(upcoming.priority).toBeGreaterThan(allDay.priority)
})
})
})

View File

@@ -0,0 +1,212 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { UnknownActionError } from "@aris/core"
import type {
ApiCalendarEvent,
CalendarEventData,
GoogleCalendarClient,
GoogleOAuthProvider,
} from "./types"
import { NextEventKey, type NextEvent } from "./calendar-context"
interface GoogleCalendarSourceBaseOptions {
calendarIds?: string[]
/** Default: 24 */
lookaheadHours?: number
}
interface GoogleCalendarSourceWithProvider extends GoogleCalendarSourceBaseOptions {
oauthProvider: GoogleOAuthProvider
client?: never
}
interface GoogleCalendarSourceWithClient extends GoogleCalendarSourceBaseOptions {
oauthProvider?: never
client: GoogleCalendarClient
}
export type GoogleCalendarSourceOptions =
| GoogleCalendarSourceWithProvider
| GoogleCalendarSourceWithClient
import { CalendarFeedItemType, type CalendarFeedItem } from "./feed-items"
import { DefaultGoogleCalendarClient } from "./google-calendar-api"
const DEFAULT_LOOKAHEAD_HOURS = 24
const PRIORITY_ONGOING = 1.0
const PRIORITY_UPCOMING_MAX = 0.9
const PRIORITY_UPCOMING_MIN = 0.3
const PRIORITY_ALL_DAY = 0.4
/**
* A FeedSource that provides Google Calendar events and next-event context.
*
* Fetches upcoming and all-day events within a configurable lookahead window.
* Provides a NextEvent context for downstream sources to react to the user's schedule.
*
* @example
* ```ts
* const calendarSource = new GoogleCalendarSource({
* oauthProvider: myOAuthProvider,
* calendarIds: ["primary", "work@example.com"],
* lookaheadHours: 12,
* })
*
* const engine = new FeedEngine()
* .register(calendarSource)
*
* // Access next-event context in downstream sources
* const next = contextValue(context, NextEventKey)
* if (next && next.minutesUntilStart < 15) {
* // remind user
* }
* ```
*/
export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
readonly id = "aris.google-calendar"
private readonly client: GoogleCalendarClient
private readonly calendarIds: string[] | undefined
private readonly lookaheadHours: number
constructor(options: GoogleCalendarSourceOptions) {
this.client = options.client ?? new DefaultGoogleCalendarClient(options.oauthProvider)
this.calendarIds = options.calendarIds
this.lookaheadHours = options.lookaheadHours ?? DEFAULT_LOOKAHEAD_HOURS
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
}
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<Partial<Context> | null> {
const events = await this.fetchAllEvents(context.time)
const now = context.time.getTime()
const nextTimedEvent = events.find((e) => !e.isAllDay && e.startTime.getTime() > now)
if (!nextTimedEvent) {
return null
}
const minutesUntilStart = (nextTimedEvent.startTime.getTime() - now) / 60_000
const nextEvent: NextEvent = {
title: nextTimedEvent.title,
startTime: nextTimedEvent.startTime,
endTime: nextTimedEvent.endTime,
minutesUntilStart,
location: nextTimedEvent.location,
}
return { [NextEventKey]: nextEvent }
}
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
const events = await this.fetchAllEvents(context.time)
const now = context.time.getTime()
const lookaheadMs = this.lookaheadHours * 60 * 60 * 1000
return events.map((event) => createFeedItem(event, now, lookaheadMs))
}
private async resolveCalendarIds(): Promise<string[]> {
if (this.calendarIds) {
return this.calendarIds
}
return this.client.listCalendarIds()
}
private async fetchAllEvents(time: Date): Promise<CalendarEventData[]> {
const timeMax = new Date(time.getTime() + this.lookaheadHours * 60 * 60 * 1000)
const calendarIds = await this.resolveCalendarIds()
const results = await Promise.all(
calendarIds.map(async (calendarId) => {
const raw = await this.client.listEvents({
calendarId,
timeMin: time,
timeMax,
})
return raw.map((event) => parseEvent(event, calendarId))
}),
)
const allEvents = results.flat()
// Sort by start time ascending
allEvents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
return allEvents
}
}
function parseEvent(event: ApiCalendarEvent, calendarId: string): CalendarEventData {
const startRaw = event.start.dateTime ?? event.start.date
const endRaw = event.end.dateTime ?? event.end.date
if (!startRaw || !endRaw) {
throw new Error(`Event ${event.id} is missing start or end date`)
}
const isAllDay = !event.start.dateTime
return {
eventId: event.id,
calendarId,
title: event.summary ?? "(No title)",
description: event.description ?? null,
location: event.location ?? null,
startTime: new Date(startRaw),
endTime: new Date(endRaw),
isAllDay,
status: event.status,
htmlLink: event.htmlLink,
}
}
function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: number): number {
if (event.isAllDay) {
return PRIORITY_ALL_DAY
}
const startMs = event.startTime.getTime()
const endMs = event.endTime.getTime()
// Ongoing: start <= now < end
if (startMs <= nowMs && nowMs < endMs) {
return PRIORITY_ONGOING
}
// Upcoming: linear decay from PRIORITY_UPCOMING_MAX to PRIORITY_UPCOMING_MIN
const msUntilStart = startMs - nowMs
if (msUntilStart <= 0) {
return PRIORITY_UPCOMING_MIN
}
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
return PRIORITY_UPCOMING_MAX - ratio * (PRIORITY_UPCOMING_MAX - PRIORITY_UPCOMING_MIN)
}
function createFeedItem(
event: CalendarEventData,
nowMs: number,
lookaheadMs: number,
): CalendarFeedItem {
const priority = computePriority(event, nowMs, lookaheadMs)
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
return {
id: `calendar-${event.calendarId}-${event.eventId}`,
type: itemType,
priority,
timestamp: new Date(nowMs),
data: event,
}
}

View File

@@ -0,0 +1,20 @@
export { NextEventKey, type NextEvent } from "./calendar-context"
export {
CalendarFeedItemType,
type CalendarFeedItemType as CalendarFeedItemTypeType,
type CalendarAllDayFeedItem,
type CalendarEventFeedItem,
type CalendarFeedItem,
} from "./feed-items"
export { DefaultGoogleCalendarClient } from "./google-calendar-api"
export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source"
export {
EventStatus,
type EventStatus as EventStatusType,
type ApiCalendarEvent,
type ApiEventDateTime,
type CalendarEventData,
type GoogleCalendarClient,
type GoogleOAuthProvider,
type ListEventsOptions,
} from "./types"

View File

@@ -0,0 +1,55 @@
export interface GoogleOAuthProvider {
fetchAccessToken(): Promise<string>
refresh(): Promise<string>
revoke(): Promise<void>
}
export const EventStatus = {
Confirmed: "confirmed",
Tentative: "tentative",
Cancelled: "cancelled",
} as const
export type EventStatus = (typeof EventStatus)[keyof typeof EventStatus]
/** Exactly one of dateTime or date is present. */
export interface ApiEventDateTime {
dateTime?: string
date?: string
timeZone?: string
}
export interface ApiCalendarEvent {
id: string
status: EventStatus
htmlLink: string
summary?: string
description?: string
location?: string
start: ApiEventDateTime
end: ApiEventDateTime
}
export type CalendarEventData = {
eventId: string
calendarId: string
title: string
description: string | null
location: string | null
startTime: Date
endTime: Date
isAllDay: boolean
status: EventStatus
htmlLink: string
}
export interface ListEventsOptions {
calendarId: string
timeMin: Date
timeMax: Date
}
export interface GoogleCalendarClient {
listCalendarIds(): Promise<string[]>
listEvents(options: ListEventsOptions): Promise<ApiCalendarEvent[]>
}

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,14 @@
{
"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:*",
"arktype": "^2.1.0"
}
}

View File

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

View File

@@ -0,0 +1,186 @@
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("aris.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 null when no location", async () => {
const source = new LocationSource()
const context = await source.fetchContext()
expect(context).toBeNull()
})
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)
})
})
describe("actions", () => {
test("listActions returns update-location action", async () => {
const source = new LocationSource()
const actions = await source.listActions()
expect(actions["update-location"]).toBeDefined()
expect(actions["update-location"]!.id).toBe("update-location")
expect(actions["update-location"]!.input).toBeDefined()
})
test("executeAction update-location pushes location", async () => {
const source = new LocationSource()
expect(source.lastLocation).toBeNull()
const location = createLocation({ lat: 40.7128, lng: -74.006 })
await source.executeAction("update-location", location)
expect(source.lastLocation).toEqual(location)
})
test("executeAction throws on invalid input", async () => {
const source = new LocationSource()
await expect(
source.executeAction("update-location", { lat: "not a number" }),
).rejects.toThrow()
})
test("executeAction throws for unknown action", async () => {
const source = new LocationSource()
await expect(source.executeAction("nonexistent", {})).rejects.toThrow("Unknown action")
})
})
})

View File

@@ -0,0 +1,98 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { UnknownActionError, contextKey, type ContextKey } from "@aris/core"
import { type } from "arktype"
import { Location, type LocationSourceOptions } from "./types.ts"
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 = "aris.location"
private readonly historySize: number
private locations: Location[] = []
private listeners = new Set<(update: Partial<Context>) => void>()
constructor(options: LocationSourceOptions = {}) {
this.historySize = options.historySize ?? 1
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {
"update-location": {
id: "update-location",
description: "Push a new location update",
input: Location,
},
}
}
async executeAction(actionId: string, params: unknown): Promise<void> {
switch (actionId) {
case "update-location": {
const result = Location(params)
if (result instanceof type.errors) {
throw new Error(result.summary)
}
this.pushLocation(result)
return
}
default:
throw new UnknownActionError(actionId)
}
}
/**
* 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> | null> {
if (this.lastLocation) {
return { [LocationKey]: this.lastLocation }
}
return null
}
async fetchItems(): Promise<[]> {
return []
}
}

View File

@@ -0,0 +1,17 @@
import { type } from "arktype"
/** Geographic coordinates with accuracy and timestamp. */
export const Location = type({
lat: "number",
lng: "number",
/** Accuracy in meters */
accuracy: "number",
timestamp: "Date",
})
export type Location = typeof Location.infer
export interface LocationSourceOptions {
/** Number of locations to retain in history. Defaults to 1. */
historySize?: number
}

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
@@ -154,7 +142,7 @@ export class TflApi implements ITflApi {
// Schemas
const lineId = type(
export const lineId = type(
"'bakerloo' | 'central' | 'circle' | 'district' | 'hammersmith-city' | 'jubilee' | 'metropolitan' | 'northern' | 'piccadilly' | 'victoria' | 'waterloo-city' | 'lioness' | 'mildmay' | 'windrush' | 'weaver' | 'suffragette' | 'liberty' | 'elizabeth'",
)

View File

@@ -0,0 +1,365 @@
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("aris.tfl")
})
test("depends on location", () => {
const source = new TflSource({ client: api })
expect(source.dependencies).toEqual(["aris.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("actions", () => {
test("listActions returns set-lines-of-interest", async () => {
const source = new TflSource({ client: api })
const actions = await source.listActions()
expect(actions["set-lines-of-interest"]).toBeDefined()
expect(actions["set-lines-of-interest"]!.id).toBe("set-lines-of-interest")
})
test("executeAction set-lines-of-interest updates lines", async () => {
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 []
},
}
const source = new TflSource({ client: lineFilteringApi })
await source.executeAction("set-lines-of-interest", ["northern"])
const items = await source.fetchItems(createContext())
expect(items.length).toBe(1)
expect(items[0]!.data.line).toBe("northern")
})
test("executeAction throws on invalid input", async () => {
const source = new TflSource({ client: api })
await expect(
source.executeAction("set-lines-of-interest", "not-an-array"),
).rejects.toThrow()
})
test("executeAction throws for unknown action", async () => {
const source = new TflSource({ client: api })
await expect(source.executeAction("nonexistent", {})).rejects.toThrow("Unknown action")
})
})
})
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,196 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { UnknownActionError, contextValue } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import { type } from "arktype"
import type {
ITflApi,
StationLocation,
TflAlertData,
TflAlertFeedItem,
TflAlertSeverity,
TflLineId,
TflSourceOptions,
} from "./types.ts"
import { TflApi, lineId } from "./tfl-api.ts"
const setLinesInput = lineId.array()
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 = "aris.tfl"
readonly dependencies = ["aris.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]
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {
"set-lines-of-interest": {
id: "set-lines-of-interest",
description: "Update the set of monitored TfL lines",
input: setLinesInput,
},
}
}
async executeAction(actionId: string, params: unknown): Promise<void> {
switch (actionId) {
case "set-lines-of-interest": {
const result = setLinesInput(params)
if (result instanceof type.errors) {
throw new Error(result.summary)
}
this.setLinesOfInterest(result)
return
}
default:
throw new UnknownActionError(actionId)
}
}
async fetchContext(): Promise<null> {
return null
}
/**
* 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,187 @@
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("aris.weather")
})
test("depends on location", () => {
const source = new WeatherSource({ credentials: mockCredentials })
expect(source.dependencies).toEqual(["aris.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 null when no location", async () => {
const source = new WeatherSource({ client: mockClient })
const result = await source.fetchContext(createMockContext())
expect(result).toBeNull()
})
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)
expect(result).not.toBeNull()
const weather = contextValue(result! as Context, 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)
expect(result).not.toBeNull()
const weather = contextValue(result! as Context, 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,371 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import { UnknownActionError, 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 = "aris.weather"
readonly dependencies = ["aris.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 listActions(): Promise<Record<string, ActionDefinition>> {
return {}
}
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<Partial<Context> | null> {
const location = contextValue(context, LocationKey)
if (!location) {
return null
}
const response = await this.client.fetch({
lat: location.lat,
lng: location.lng,
})
if (!response.currentWeather) {
return null
}
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}`
}