mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 00:51:20 +00:00
Rename all references across the codebase: package names, imports, source IDs, directory names, docs, and configs. Co-authored-by: Ona <no-reply@ona.com>
307 lines
11 KiB
Markdown
307 lines
11 KiB
Markdown
# 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/aelis-backend`). No changes to `aelis-core` or source packages (`packages/aelis-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?
|