diff --git a/.gitignore b/.gitignore index a14702c..e8b056e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store +core diff --git a/apps/aris-backend/src/location/service.test.ts b/apps/aris-backend/src/location/service.test.ts index a25d46a..bad7cfb 100644 --- a/apps/aris-backend/src/location/service.test.ts +++ b/apps/aris-backend/src/location/service.test.ts @@ -9,7 +9,7 @@ describe("LocationService", () => { const source = service.feedSourceForUser("user-1") expect(source).toBeDefined() - expect(source.id).toBe("location") + expect(source.id).toBe("aris.location") }) test("feedSourceForUser returns same source for same user", () => { diff --git a/apps/aris-backend/src/tfl/service.test.ts b/apps/aris-backend/src/tfl/service.test.ts index 32611e6..be6d802 100644 --- a/apps/aris-backend/src/tfl/service.test.ts +++ b/apps/aris-backend/src/tfl/service.test.ts @@ -64,7 +64,7 @@ describe("TflService", () => { const source = service.feedSourceForUser("user-1") expect(source).toBeDefined() - expect(source.id).toBe("tfl") + expect(source.id).toBe("aris.tfl") }) test("feedSourceForUser returns same source for same user", () => { @@ -128,8 +128,8 @@ describe("TflService", () => { service.feedSourceForUser("user-1") service.feedSourceForUser("user-2") - expect(service.feedSourceForUser("user-1").id).toBe("tfl") - expect(service.feedSourceForUser("user-2").id).toBe("tfl") + expect(service.feedSourceForUser("user-1").id).toBe("aris.tfl") + expect(service.feedSourceForUser("user-2").id).toBe("aris.tfl") }) describe("returned source fetches items", () => { diff --git a/apps/aris-backend/src/weather/service.test.ts b/apps/aris-backend/src/weather/service.test.ts index 4925890..8a4f64b 100644 --- a/apps/aris-backend/src/weather/service.test.ts +++ b/apps/aris-backend/src/weather/service.test.ts @@ -34,7 +34,7 @@ describe("WeatherService", () => { const source = service.feedSourceForUser("user-1") expect(source).toBeDefined() - expect(source.id).toBe("weather") + expect(source.id).toBe("aris.weather") }) test("feedSourceForUser returns same source for same user", () => { diff --git a/bun.lock b/bun.lock index 4907d29..8f426ba 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,9 @@ "packages/aris-core": { "name": "@aris/core", "version": "0.0.0", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + }, }, "packages/aris-data-source-weatherkit": { "name": "@aris/data-source-weatherkit", @@ -66,6 +69,7 @@ "version": "0.0.0", "dependencies": { "@aris/core": "workspace:*", + "arktype": "^2.1.0", }, }, "packages/aris-source-tfl": { diff --git a/docs/backend-service-architecture-spec.md b/docs/backend-service-architecture-spec.md new file mode 100644 index 0000000..34ed763 --- /dev/null +++ b/docs/backend-service-architecture-spec.md @@ -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`. 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 + ├── new WeatherService(creds) ← owns Map + ├── new TflService(api) ← owns Map + └── FeedEngineService([loc, weather, tfl]) + └── owns Map + └── 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 + + 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(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() + + 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("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("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() + private userSources = new Map>() + + constructor(private readonly factories: SourceFactory[]) {} + + engineForUser(userId: string): FeedEngine { ... } + getSourceForUser(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 { + readonly source: T +} + +class UserSession { + readonly engine: FeedEngine + private handles = new Map() + + register(source: T): SourceHandle { + this.engine.register(source) + const handle: SourceHandle = { 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? diff --git a/docs/feed-source-actions-spec.md b/docs/feed-source-actions-spec.md new file mode 100644 index 0000000..925ba42 --- /dev/null +++ b/docs/feed-source-actions-spec.md @@ -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.`. 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 `/`. + +| 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 { + /** 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 +} +``` + +`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 + 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 +} +``` + +### Changes to FeedSource + +Two optional fields added. Nothing else changes. + +```typescript +interface FeedSource { + 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> + + /** Execute an action by ID. No-op returning { ok: false } if source has no actions. */ + executeAction( + actionId: string, + params: Record, + ): Promise +} +``` + +### Changes to FeedItem + +One optional field added. + +```typescript +interface FeedItem< + TType extends string = string, + TData extends Record = Record, +> { + 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, + ): Promise + + /** List all actions across all registered sources. */ + listActions(): { sourceId: string; actions: readonly ActionDefinition[] }[] +} +``` + +### Example: Spotify Source + +```typescript +class SpotifySource implements FeedSource { + 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): Promise { + 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 { + return null + } + + // Note: for a source with no actions, it would be: + // async listActions() { return {} } + // async executeAction(): Promise { + // return { ok: false, error: "No actions supported" } + // } + + async fetchItems(context: Context): Promise { + 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` (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) diff --git a/packages/aris-core/package.json b/packages/aris-core/package.json index c8106bb..4efcc73 100644 --- a/packages/aris-core/package.json +++ b/packages/aris-core/package.json @@ -6,5 +6,8 @@ "types": "src/index.ts", "scripts": { "test": "bun test ." + }, + "dependencies": { + "@standard-schema/spec": "^1.1.0" } } diff --git a/packages/aris-core/src/action.ts b/packages/aris-core/src/action.ts new file mode 100644 index 0000000..374f089 --- /dev/null +++ b/packages/aris-core/src/action.ts @@ -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: + * `/` (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 { + /** 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 +} diff --git a/packages/aris-core/src/feed-engine.test.ts b/packages/aris-core/src/feed-engine.test.ts index 30291df..6dd6435 100644 --- a/packages/aris-core/src/feed-engine.test.ts +++ b/packages/aris-core/src/feed-engine.test.ts @@ -1,9 +1,19 @@ 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 { FeedEngine } from "./feed-engine" -import { contextKey, contextValue } from "./index" +import { UnknownActionError, contextKey, contextValue } from "./index" + +// No-op action methods for test sources +const noActions = { + async listActions(): Promise> { + return {} + }, + async executeAction(actionId: string): Promise { + throw new UnknownActionError(actionId) + }, +} // ============================================================================= // CONTEXT KEYS @@ -43,6 +53,7 @@ function createLocationSource(): SimulatedLocationSource { return { id: "location", + ...noActions, onContextUpdate(cb) { callback = cb @@ -71,6 +82,7 @@ function createWeatherSource( return { id: "weather", dependencies: ["location"], + ...noActions, async fetchContext(context) { const location = contextValue(context, LocationKey) @@ -104,6 +116,7 @@ function createAlertSource(): FeedSource { return { id: "alert", dependencies: ["weather"], + ...noActions, async fetchContext() { return null @@ -168,11 +181,12 @@ describe("FeedEngine", () => { }) describe("graph validation", () => { - test("throws on missing dependency", () => { + test("throws on missing dependency", async () => { const engine = new FeedEngine() const orphan: FeedSource = { id: "orphan", dependencies: ["nonexistent"], + ...noActions, async fetchContext() { return null }, @@ -180,16 +194,17 @@ describe("FeedEngine", () => { engine.register(orphan) - expect(engine.refresh()).rejects.toThrow( + await expect(engine.refresh()).rejects.toThrow( 'Source "orphan" depends on "nonexistent" which is not registered', ) }) - test("throws on circular dependency", () => { + test("throws on circular dependency", async () => { const engine = new FeedEngine() const a: FeedSource = { id: "a", dependencies: ["b"], + ...noActions, async fetchContext() { return null }, @@ -197,6 +212,7 @@ describe("FeedEngine", () => { const b: FeedSource = { id: "b", dependencies: ["a"], + ...noActions, async fetchContext() { return null }, @@ -204,14 +220,15 @@ describe("FeedEngine", () => { engine.register(a).register(b) - expect(engine.refresh()).rejects.toThrow("Circular dependency detected: a → b → a") + await expect(engine.refresh()).rejects.toThrow("Circular dependency detected: a → b → a") }) - test("throws on longer cycles", () => { + test("throws on longer cycles", async () => { const engine = new FeedEngine() const a: FeedSource = { id: "a", dependencies: ["c"], + ...noActions, async fetchContext() { return null }, @@ -219,6 +236,7 @@ describe("FeedEngine", () => { const b: FeedSource = { id: "b", dependencies: ["a"], + ...noActions, async fetchContext() { return null }, @@ -226,6 +244,7 @@ describe("FeedEngine", () => { const c: FeedSource = { id: "c", dependencies: ["b"], + ...noActions, async fetchContext() { return null }, @@ -233,7 +252,7 @@ describe("FeedEngine", () => { engine.register(a).register(b).register(c) - expect(engine.refresh()).rejects.toThrow("Circular dependency detected") + await expect(engine.refresh()).rejects.toThrow("Circular dependency detected") }) }) @@ -243,6 +262,7 @@ describe("FeedEngine", () => { const location: FeedSource = { id: "location", + ...noActions, async fetchContext() { order.push("location") return { [LocationKey]: { lat: 51.5, lng: -0.1 } } @@ -252,6 +272,7 @@ describe("FeedEngine", () => { const weather: FeedSource = { id: "weather", dependencies: ["location"], + ...noActions, async fetchContext(ctx) { order.push("weather") const loc = contextValue(ctx, LocationKey) @@ -277,8 +298,14 @@ describe("FeedEngine", () => { 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" }) + 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 () => { @@ -318,6 +345,7 @@ describe("FeedEngine", () => { test("handles missing upstream context gracefully", async () => { const location: FeedSource = { id: "location", + ...noActions, async fetchContext() { return null // No location available }, @@ -336,6 +364,7 @@ describe("FeedEngine", () => { test("captures errors from fetchContext", async () => { const failing: FeedSource = { id: "failing", + ...noActions, async fetchContext() { throw new Error("Context fetch failed") }, @@ -353,6 +382,7 @@ describe("FeedEngine", () => { test("captures errors from fetchItems", async () => { const failing: FeedSource = { id: "failing", + ...noActions, async fetchContext() { return null }, @@ -373,6 +403,7 @@ describe("FeedEngine", () => { test("continues after source error", async () => { const failing: FeedSource = { id: "failing", + ...noActions, async fetchContext() { throw new Error("Failed") }, @@ -380,6 +411,7 @@ describe("FeedEngine", () => { const working: FeedSource = { id: "working", + ...noActions, async fetchContext() { return null }, @@ -423,7 +455,10 @@ describe("FeedEngine", () => { await engine.refresh() const context = engine.currentContext() - expect(contextValue(context, LocationKey)).toEqual({ lat: 51.5, lng: -0.1 }) + expect(contextValue(context, LocationKey)).toEqual({ + lat: 51.5, + lng: -0.1, + }) }) }) @@ -498,4 +533,109 @@ describe("FeedEngine", () => { 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"', + ) + }) + }) }) diff --git a/packages/aris-core/src/feed-engine.ts b/packages/aris-core/src/feed-engine.ts index 18ff4b4..a55e34e 100644 --- a/packages/aris-core/src/feed-engine.ts +++ b/packages/aris-core/src/feed-engine.ts @@ -1,3 +1,4 @@ +import type { ActionDefinition } from "./action" import type { Context } from "./context" import type { FeedItem } from "./feed" import type { FeedSource } from "./feed-source" @@ -187,6 +188,44 @@ export class FeedEngine { 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 { + 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> { + 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())) @@ -240,7 +279,11 @@ export class FeedEngine { items.sort((a, b) => b.priority - a.priority) - this.notifySubscribers({ context: this.context, items: items as TItems[], errors }) + this.notifySubscribers({ + context: this.context, + items: items as TItems[], + errors, + }) } private collectDependents(sourceId: string, graph: SourceGraph): string[] { diff --git a/packages/aris-core/src/feed-source.test.ts b/packages/aris-core/src/feed-source.test.ts index 9b9a613..4804aab 100644 --- a/packages/aris-core/src/feed-source.test.ts +++ b/packages/aris-core/src/feed-source.test.ts @@ -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> { + return {} + }, + async executeAction(actionId: string): Promise { + throw new UnknownActionError(actionId) + }, +} // ============================================================================= // CONTEXT KEYS @@ -42,6 +52,7 @@ function createLocationSource(): SimulatedLocationSource { return { id: "location", + ...noActions, onContextUpdate(cb) { callback = cb @@ -70,6 +81,7 @@ function createWeatherSource( return { id: "weather", dependencies: ["location"], + ...noActions, async fetchContext(context) { const location = contextValue(context, LocationKey) @@ -103,6 +115,7 @@ function createAlertSource(): FeedSource { return { id: "alert", dependencies: ["weather"], + ...noActions, async fetchContext() { return null @@ -265,6 +278,7 @@ describe("FeedSource", () => { const orphan: FeedSource = { id: "orphan", dependencies: ["nonexistent"], + ...noActions, async fetchContext() { return null }, @@ -279,6 +293,7 @@ describe("FeedSource", () => { const a: FeedSource = { id: "a", dependencies: ["b"], + ...noActions, async fetchContext() { return null }, @@ -286,6 +301,7 @@ describe("FeedSource", () => { const b: FeedSource = { id: "b", dependencies: ["a"], + ...noActions, async fetchContext() { return null }, @@ -298,6 +314,7 @@ describe("FeedSource", () => { const a: FeedSource = { id: "a", dependencies: ["c"], + ...noActions, async fetchContext() { return null }, @@ -305,6 +322,7 @@ describe("FeedSource", () => { const b: FeedSource = { id: "b", dependencies: ["a"], + ...noActions, async fetchContext() { return null }, @@ -312,6 +330,7 @@ describe("FeedSource", () => { const c: FeedSource = { id: "c", dependencies: ["b"], + ...noActions, async fetchContext() { return null }, @@ -350,6 +369,7 @@ describe("FeedSource", () => { const location: FeedSource = { id: "location", + ...noActions, async fetchContext() { order.push("location") return { [LocationKey]: { lat: 51.5, lng: -0.1 } } @@ -359,6 +379,7 @@ describe("FeedSource", () => { const weather: FeedSource = { id: "weather", dependencies: ["location"], + ...noActions, async fetchContext(ctx) { order.push("weather") const loc = contextValue(ctx, LocationKey) @@ -382,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 () => { @@ -422,6 +449,7 @@ describe("FeedSource", () => { // Location source exists but hasn't been updated const location: FeedSource = { id: "location", + ...noActions, async fetchContext() { // Simulate no location available return null diff --git a/packages/aris-core/src/feed-source.ts b/packages/aris-core/src/feed-source.ts index cffbf93..df577ff 100644 --- a/packages/aris-core/src/feed-source.ts +++ b/packages/aris-core/src/feed-source.ts @@ -1,61 +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.`, + * 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 = { - * id: "weather", - * dependencies: ["location"], - * fetchContext: async (ctx) => { - * const weather = await fetchWeather(ctx.location) - * return { weather } - * }, - * fetchItems: async (ctx) => { - * return createWeatherFeedItems(ctx.weather) - * }, - * } - * - * // TFL source - no context to provide - * const tflSource: FeedSource = { - * id: "tfl", - * fetchContext: async () => null, - * fetchItems: async (ctx) => { ... }, + * id: "aris.location", + * async listActions() { return { "update-location": { id: "update-location" } } }, + * async executeAction(actionId) { throw new UnknownActionError(actionId) }, + * async fetchContext() { ... }, * } * ``` */ export interface FeedSource { - /** 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> + + /** + * Execute an action by ID. Throws on unknown action or invalid input. + * Maps to: source/executeAction + */ + executeAction(actionId: string, params: unknown): Promise + /** * 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) => void, @@ -66,6 +65,7 @@ export interface FeedSource { * 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 | null> @@ -73,12 +73,14 @@ export interface FeedSource { * 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 } diff --git a/packages/aris-core/src/index.ts b/packages/aris-core/src/index.ts index 691e5c8..0c5f5b8 100644 --- a/packages/aris-core/src/index.ts +++ b/packages/aris-core/src/index.ts @@ -2,6 +2,10 @@ 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" diff --git a/packages/aris-data-source-weatherkit/src/data-source.test.ts b/packages/aris-data-source-weatherkit/src/data-source.test.ts index cf0a7aa..4be9dba 100644 --- a/packages/aris-data-source-weatherkit/src/data-source.test.ts +++ b/packages/aris-data-source-weatherkit/src/data-source.test.ts @@ -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) diff --git a/packages/aris-source-apple-calendar/src/calendar-source.test.ts b/packages/aris-source-apple-calendar/src/calendar-source.test.ts index 4544a4f..5523a69 100644 --- a/packages/aris-source-apple-calendar/src/calendar-source.test.ts +++ b/packages/aris-source-apple-calendar/src/calendar-source.test.ts @@ -78,7 +78,7 @@ class MockDAVClient implements CalendarDAVClient { describe("CalendarSource", () => { test("has correct id", () => { const source = new CalendarSource(new NullCredentialProvider(), "user-1") - expect(source.id).toBe("apple-calendar") + expect(source.id).toBe("aris.apple-calendar") }) test("returns empty array when credentials are null", async () => { @@ -121,7 +121,10 @@ describe("CalendarSource", () => { const objects: Record = { "/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") }, + { + url: "/cal/personal/event2.ics", + data: loadFixture("all-day-event.ics"), + }, ], } const client = new MockDAVClient( @@ -206,7 +209,12 @@ describe("CalendarSource", () => { test("handles calendar with non-string displayName", async () => { const objects: Record = { - "/cal/weird": [{ url: "/cal/weird/event1.ics", data: loadFixture("minimal-event.ics") }], + "/cal/weird": [ + { + url: "/cal/weird/event1.ics", + data: loadFixture("minimal-event.ics"), + }, + ], } const client = new MockDAVClient( [{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }], @@ -222,7 +230,12 @@ describe("CalendarSource", () => { test("handles recurring events with exceptions", async () => { const objects: Record = { - "/cal/work": [{ url: "/cal/work/recurring.ics", data: loadFixture("recurring-event.ics") }], + "/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", { diff --git a/packages/aris-source-apple-calendar/src/calendar-source.ts b/packages/aris-source-apple-calendar/src/calendar-source.ts index 643120a..309e63d 100644 --- a/packages/aris-source-apple-calendar/src/calendar-source.ts +++ b/packages/aris-source-apple-calendar/src/calendar-source.ts @@ -1,4 +1,5 @@ -import type { Context, FeedSource } from "@aris/core" +import type { ActionDefinition, Context, FeedSource } from "@aris/core" +import { UnknownActionError } from "@aris/core" import { DAVClient } from "tsdav" @@ -37,7 +38,7 @@ const DEFAULT_LOOK_AHEAD_DAYS = 0 * ``` */ export class CalendarSource implements FeedSource { - readonly id = "apple-calendar" + readonly id = "aris.apple-calendar" private readonly credentialProvider: CalendarCredentialProvider private readonly userId: string @@ -58,6 +59,14 @@ export class CalendarSource implements FeedSource { this.injectedClient = options?.davClient ?? null } + async listActions(): Promise> { + return {} + } + + async executeAction(actionId: string): Promise { + throw new UnknownActionError(actionId) + } + async fetchContext(context: Context): Promise | null> { const events = await this.fetchEvents(context) if (events.length === 0) { diff --git a/packages/aris-source-google-calendar/src/google-calendar-source.test.ts b/packages/aris-source-google-calendar/src/google-calendar-source.test.ts index 6f1cb84..588a192 100644 --- a/packages/aris-source-google-calendar/src/google-calendar-source.test.ts +++ b/packages/aris-source-google-calendar/src/google-calendar-source.test.ts @@ -45,13 +45,15 @@ describe("GoogleCalendarSource", () => { describe("constructor", () => { test("has correct id", () => { const source = new GoogleCalendarSource({ client: defaultMockClient() }) - expect(source.id).toBe("google-calendar") + 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 source = new GoogleCalendarSource({ + client: createMockClient({ primary: [] }), + }) const items = await source.fetchItems(createContext()) expect(items).toEqual([]) }) @@ -198,7 +200,9 @@ describe("GoogleCalendarSource", () => { describe("fetchContext", () => { test("returns null when no events", async () => { - const source = new GoogleCalendarSource({ client: createMockClient({ primary: [] }) }) + const source = new GoogleCalendarSource({ + client: createMockClient({ primary: [] }), + }) const result = await source.fetchContext(createContext()) expect(result).toBeNull() }) diff --git a/packages/aris-source-google-calendar/src/google-calendar-source.ts b/packages/aris-source-google-calendar/src/google-calendar-source.ts index bba33ed..7aba03a 100644 --- a/packages/aris-source-google-calendar/src/google-calendar-source.ts +++ b/packages/aris-source-google-calendar/src/google-calendar-source.ts @@ -1,4 +1,6 @@ -import type { Context, FeedSource } from "@aris/core" +import type { ActionDefinition, Context, FeedSource } from "@aris/core" + +import { UnknownActionError } from "@aris/core" import type { ApiCalendarEvent, @@ -63,7 +65,7 @@ const PRIORITY_ALL_DAY = 0.4 * ``` */ export class GoogleCalendarSource implements FeedSource { - readonly id = "google-calendar" + readonly id = "aris.google-calendar" private readonly client: GoogleCalendarClient private readonly calendarIds: string[] | undefined @@ -75,6 +77,14 @@ export class GoogleCalendarSource implements FeedSource { this.lookaheadHours = options.lookaheadHours ?? DEFAULT_LOOKAHEAD_HOURS } + async listActions(): Promise> { + return {} + } + + async executeAction(actionId: string): Promise { + throw new UnknownActionError(actionId) + } + async fetchContext(context: Context): Promise | null> { const events = await this.fetchAllEvents(context.time) diff --git a/packages/aris-source-location/package.json b/packages/aris-source-location/package.json index 983e38d..bd7c59c 100644 --- a/packages/aris-source-location/package.json +++ b/packages/aris-source-location/package.json @@ -8,6 +8,7 @@ "test": "bun test src/" }, "dependencies": { - "@aris/core": "workspace:*" + "@aris/core": "workspace:*", + "arktype": "^2.1.0" } } diff --git a/packages/aris-source-location/src/index.ts b/packages/aris-source-location/src/index.ts index 3dce72f..aeef01a 100644 --- a/packages/aris-source-location/src/index.ts +++ b/packages/aris-source-location/src/index.ts @@ -1,6 +1,2 @@ -export { - LocationSource, - LocationKey, - type Location, - type LocationSourceOptions, -} from "./location-source.ts" +export { LocationSource, LocationKey } from "./location-source.ts" +export { Location, type LocationSourceOptions } from "./types.ts" diff --git a/packages/aris-source-location/src/location-source.test.ts b/packages/aris-source-location/src/location-source.test.ts index e6a63b4..af76707 100644 --- a/packages/aris-source-location/src/location-source.test.ts +++ b/packages/aris-source-location/src/location-source.test.ts @@ -16,7 +16,7 @@ describe("LocationSource", () => { describe("FeedSource interface", () => { test("has correct id", () => { const source = new LocationSource() - expect(source.id).toBe("location") + expect(source.id).toBe("aris.location") }) test("fetchItems always returns empty array", async () => { @@ -147,4 +147,40 @@ describe("LocationSource", () => { 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") + }) + }) }) diff --git a/packages/aris-source-location/src/location-source.ts b/packages/aris-source-location/src/location-source.ts index 4462a1a..20e6ec2 100644 --- a/packages/aris-source-location/src/location-source.ts +++ b/packages/aris-source-location/src/location-source.ts @@ -1,22 +1,9 @@ -import type { Context, FeedSource } from "@aris/core" +import type { ActionDefinition, Context, FeedSource } from "@aris/core" -import { contextKey, type ContextKey } from "@aris/core" +import { UnknownActionError, contextKey, type ContextKey } from "@aris/core" +import { type } from "arktype" -/** - * Geographic coordinates with accuracy and timestamp. - */ -export interface Location { - lat: number - lng: number - /** Accuracy in meters */ - accuracy: number - timestamp: Date -} - -export interface LocationSourceOptions { - /** Number of locations to retain in history. Defaults to 1. */ - historySize?: number -} +import { Location, type LocationSourceOptions } from "./types.ts" export const LocationKey: ContextKey = contextKey("location") @@ -29,7 +16,7 @@ export const LocationKey: ContextKey = contextKey("location") * Does not produce feed items - always returns empty array from `fetchItems`. */ export class LocationSource implements FeedSource { - readonly id = "location" + readonly id = "aris.location" private readonly historySize: number private locations: Location[] = [] @@ -39,6 +26,31 @@ export class LocationSource implements FeedSource { this.historySize = options.historySize ?? 1 } + async listActions(): Promise> { + return { + "update-location": { + id: "update-location", + description: "Push a new location update", + input: Location, + }, + } + } + + async executeAction(actionId: string, params: unknown): Promise { + 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. */ diff --git a/packages/aris-source-location/src/types.ts b/packages/aris-source-location/src/types.ts new file mode 100644 index 0000000..28376a3 --- /dev/null +++ b/packages/aris-source-location/src/types.ts @@ -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 +} diff --git a/packages/aris-source-tfl/src/tfl-api.ts b/packages/aris-source-tfl/src/tfl-api.ts index 614398c..e2f7e98 100644 --- a/packages/aris-source-tfl/src/tfl-api.ts +++ b/packages/aris-source-tfl/src/tfl-api.ts @@ -142,7 +142,7 @@ export class TflApi { // 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'", ) diff --git a/packages/aris-source-tfl/src/tfl-source.test.ts b/packages/aris-source-tfl/src/tfl-source.test.ts index e85704e..7a7c667 100644 --- a/packages/aris-source-tfl/src/tfl-source.test.ts +++ b/packages/aris-source-tfl/src/tfl-source.test.ts @@ -94,12 +94,12 @@ describe("TflSource", () => { describe("interface", () => { test("has correct id", () => { const source = new TflSource({ client: api }) - expect(source.id).toBe("tfl") + expect(source.id).toBe("aris.tfl") }) test("depends on location", () => { const source = new TflSource({ client: api }) - expect(source.dependencies).toEqual(["location"]) + expect(source.dependencies).toEqual(["aris.location"]) }) test("implements fetchItems", () => { @@ -122,7 +122,12 @@ describe("TflSource", () => { severity: "minor-delays", description: "Delays", }, - { lineId: "central", lineName: "Central", severity: "closure", description: "Closed" }, + { + lineId: "central", + lineName: "Central", + severity: "closure", + description: "Closed", + }, ] return lines ? all.filter((s) => lines.includes(s.lineId)) : all }, @@ -144,7 +149,10 @@ describe("TflSource", () => { }) test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => { - const source = new TflSource({ client: lineFilteringApi, lines: ["northern"] }) + const source = new TflSource({ + client: lineFilteringApi, + lines: ["northern"], + }) const filtered = await source.fetchItems(createContext()) expect(filtered.length).toBe(1) @@ -164,7 +172,12 @@ describe("TflSource", () => { 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 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) { @@ -178,7 +191,12 @@ describe("TflSource", () => { 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 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) { @@ -230,7 +248,12 @@ describe("TflSource", () => { 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 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) { @@ -248,6 +271,62 @@ describe("TflSource", () => { } }) }) + + 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 { + 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 { + 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", () => { diff --git a/packages/aris-source-tfl/src/tfl-source.ts b/packages/aris-source-tfl/src/tfl-source.ts index 5c63d4a..d9f40bf 100644 --- a/packages/aris-source-tfl/src/tfl-source.ts +++ b/packages/aris-source-tfl/src/tfl-source.ts @@ -1,7 +1,8 @@ -import type { Context, FeedSource } from "@aris/core" +import type { ActionDefinition, Context, FeedSource } from "@aris/core" -import { contextValue } from "@aris/core" +import { UnknownActionError, contextValue } from "@aris/core" import { LocationKey } from "@aris/source-location" +import { type } from "arktype" import type { ITflApi, @@ -13,7 +14,9 @@ import type { TflSourceOptions, } from "./types.ts" -import { TflApi } from "./tfl-api.ts" +import { TflApi, lineId } from "./tfl-api.ts" + +const setLinesInput = lineId.array() const SEVERITY_PRIORITY: Record = { closure: 1.0, @@ -63,8 +66,8 @@ export class TflSource implements FeedSource { "elizabeth", ] - readonly id = "tfl" - readonly dependencies = ["location"] + readonly id = "aris.tfl" + readonly dependencies = ["aris.location"] private readonly client: ITflApi private lines: TflLineId[] @@ -77,6 +80,31 @@ export class TflSource implements FeedSource { this.lines = options.lines ?? [...TflSource.DEFAULT_LINES_OF_INTEREST] } + async listActions(): Promise> { + 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 { + 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 { return null } diff --git a/packages/aris-source-weatherkit/src/weather-source.test.ts b/packages/aris-source-weatherkit/src/weather-source.test.ts index 2ce8b0c..c649f3f 100644 --- a/packages/aris-source-weatherkit/src/weather-source.test.ts +++ b/packages/aris-source-weatherkit/src/weather-source.test.ts @@ -34,12 +34,12 @@ describe("WeatherSource", () => { describe("properties", () => { test("has correct id", () => { const source = new WeatherSource({ credentials: mockCredentials }) - expect(source.id).toBe("weather") + expect(source.id).toBe("aris.weather") }) test("depends on location", () => { const source = new WeatherSource({ credentials: mockCredentials }) - expect(source.dependencies).toEqual(["location"]) + expect(source.dependencies).toEqual(["aris.location"]) }) test("throws error if neither client nor credentials provided", () => { @@ -78,7 +78,10 @@ describe("WeatherSource", () => { }) test("converts temperature to imperial", async () => { - const source = new WeatherSource({ client: mockClient, units: Units.imperial }) + const source = new WeatherSource({ + client: mockClient, + units: Units.imperial, + }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const result = await source.fetchContext(context) diff --git a/packages/aris-source-weatherkit/src/weather-source.ts b/packages/aris-source-weatherkit/src/weather-source.ts index d1c32f6..a1390c2 100644 --- a/packages/aris-source-weatherkit/src/weather-source.ts +++ b/packages/aris-source-weatherkit/src/weather-source.ts @@ -1,6 +1,6 @@ -import type { Context, FeedSource } from "@aris/core" +import type { ActionDefinition, Context, FeedSource } from "@aris/core" -import { contextValue } from "@aris/core" +import { UnknownActionError, contextValue } from "@aris/core" import { LocationKey } from "@aris/source-location" import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items" @@ -93,8 +93,8 @@ const MODERATE_CONDITIONS = new Set([ * ``` */ export class WeatherSource implements FeedSource { - readonly id = "weather" - readonly dependencies = ["location"] + readonly id = "aris.weather" + readonly dependencies = ["aris.location"] private readonly client: WeatherKitClient private readonly hourlyLimit: number @@ -111,6 +111,14 @@ export class WeatherSource implements FeedSource { this.units = options.units ?? Units.metric } + async listActions(): Promise> { + return {} + } + + async executeAction(actionId: string): Promise { + throw new UnknownActionError(actionId) + } + async fetchContext(context: Context): Promise | null> { const location = contextValue(context, LocationKey) if (!location) {