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>
This commit is contained in:
2026-02-15 12:26:23 +00:00
parent 4d6cac7ec8
commit 699155e0d8
29 changed files with 1169 additions and 116 deletions

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?