Files
aris/apps/aelis-backend/src/session/user-session-manager.ts

286 lines
8.6 KiB
TypeScript
Raw Normal View History

import type { FeedSource } from "@aelis/core"
import { type } from "arktype"
import merge from "lodash.merge"
import type { Database } from "../db/index.ts"
feat(backend): add LLM-powered feed enhancement (#58) * feat(backend): add LLM-powered feed enhancement Add enhancement harness that fills feed item slots and generates synthetic items via OpenRouter. - LLM client with 30s timeout, reusable SDK instance - Prompt builder with mini calendar and week overview - arktype schema validation + JSON Schema for structured output - Pure merge function with clock injection - Defensive fallback in feed endpoint on enhancement failure - Skips LLM call when no unfilled slots or no API key Co-authored-by: Ona <no-reply@ona.com> * refactor: move feed enhancement into UserSession Move enhancement logic from HTTP handler into UserSession so the transport layer has no knowledge of enhancement. UserSession.feed() handles refresh, enhancement, and caching in one place. - UserSession subscribes to engine updates and re-enhances eagerly - Enhancement cache tracks source identity to prevent stale results - UserSessionManager accepts config object with optional enhancer - HTTP handler simplified to just call session.feed() Co-authored-by: Ona <no-reply@ona.com> * test: add schema sync tests for arktype/JSON Schema drift Validates reference payloads against both the arktype schema (parseEnhancementResult) and the OpenRouter JSON Schema structure. Catches field additions/removals or type changes in either schema. Co-authored-by: Ona <no-reply@ona.com> * refactor: rename arktype schemas to match types Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
2026-03-05 02:01:30 +00:00
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import { InvalidSourceConfigError, SourceNotFoundError } from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { UserSession } from "./user-session.ts"
feat(backend): add LLM-powered feed enhancement (#58) * feat(backend): add LLM-powered feed enhancement Add enhancement harness that fills feed item slots and generates synthetic items via OpenRouter. - LLM client with 30s timeout, reusable SDK instance - Prompt builder with mini calendar and week overview - arktype schema validation + JSON Schema for structured output - Pure merge function with clock injection - Defensive fallback in feed endpoint on enhancement failure - Skips LLM call when no unfilled slots or no API key Co-authored-by: Ona <no-reply@ona.com> * refactor: move feed enhancement into UserSession Move enhancement logic from HTTP handler into UserSession so the transport layer has no knowledge of enhancement. UserSession.feed() handles refresh, enhancement, and caching in one place. - UserSession subscribes to engine updates and re-enhances eagerly - Enhancement cache tracks source identity to prevent stale results - UserSessionManager accepts config object with optional enhancer - HTTP handler simplified to just call session.feed() Co-authored-by: Ona <no-reply@ona.com> * test: add schema sync tests for arktype/JSON Schema drift Validates reference payloads against both the arktype schema (parseEnhancementResult) and the OpenRouter JSON Schema structure. Catches field additions/removals or type changes in either schema. Co-authored-by: Ona <no-reply@ona.com> * refactor: rename arktype schemas to match types Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
2026-03-05 02:01:30 +00:00
export interface UserSessionManagerConfig {
db: Database
providers: FeedSourceProvider[]
feat(backend): add LLM-powered feed enhancement (#58) * feat(backend): add LLM-powered feed enhancement Add enhancement harness that fills feed item slots and generates synthetic items via OpenRouter. - LLM client with 30s timeout, reusable SDK instance - Prompt builder with mini calendar and week overview - arktype schema validation + JSON Schema for structured output - Pure merge function with clock injection - Defensive fallback in feed endpoint on enhancement failure - Skips LLM call when no unfilled slots or no API key Co-authored-by: Ona <no-reply@ona.com> * refactor: move feed enhancement into UserSession Move enhancement logic from HTTP handler into UserSession so the transport layer has no knowledge of enhancement. UserSession.feed() handles refresh, enhancement, and caching in one place. - UserSession subscribes to engine updates and re-enhances eagerly - Enhancement cache tracks source identity to prevent stale results - UserSessionManager accepts config object with optional enhancer - HTTP handler simplified to just call session.feed() Co-authored-by: Ona <no-reply@ona.com> * test: add schema sync tests for arktype/JSON Schema drift Validates reference payloads against both the arktype schema (parseEnhancementResult) and the OpenRouter JSON Schema structure. Catches field additions/removals or type changes in either schema. Co-authored-by: Ona <no-reply@ona.com> * refactor: rename arktype schemas to match types Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
2026-03-05 02:01:30 +00:00
feedEnhancer?: FeedEnhancer | null
}
export class UserSessionManager {
private sessions = new Map<string, UserSession>()
private pending = new Map<string, Promise<UserSession>>()
private readonly db: Database
private readonly providers = new Map<string, FeedSourceProvider>()
feat(backend): add LLM-powered feed enhancement (#58) * feat(backend): add LLM-powered feed enhancement Add enhancement harness that fills feed item slots and generates synthetic items via OpenRouter. - LLM client with 30s timeout, reusable SDK instance - Prompt builder with mini calendar and week overview - arktype schema validation + JSON Schema for structured output - Pure merge function with clock injection - Defensive fallback in feed endpoint on enhancement failure - Skips LLM call when no unfilled slots or no API key Co-authored-by: Ona <no-reply@ona.com> * refactor: move feed enhancement into UserSession Move enhancement logic from HTTP handler into UserSession so the transport layer has no knowledge of enhancement. UserSession.feed() handles refresh, enhancement, and caching in one place. - UserSession subscribes to engine updates and re-enhances eagerly - Enhancement cache tracks source identity to prevent stale results - UserSessionManager accepts config object with optional enhancer - HTTP handler simplified to just call session.feed() Co-authored-by: Ona <no-reply@ona.com> * test: add schema sync tests for arktype/JSON Schema drift Validates reference payloads against both the arktype schema (parseEnhancementResult) and the OpenRouter JSON Schema structure. Catches field additions/removals or type changes in either schema. Co-authored-by: Ona <no-reply@ona.com> * refactor: rename arktype schemas to match types Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
2026-03-05 02:01:30 +00:00
private readonly feedEnhancer: FeedEnhancer | null
private readonly db: Database
feat(backend): add LLM-powered feed enhancement (#58) * feat(backend): add LLM-powered feed enhancement Add enhancement harness that fills feed item slots and generates synthetic items via OpenRouter. - LLM client with 30s timeout, reusable SDK instance - Prompt builder with mini calendar and week overview - arktype schema validation + JSON Schema for structured output - Pure merge function with clock injection - Defensive fallback in feed endpoint on enhancement failure - Skips LLM call when no unfilled slots or no API key Co-authored-by: Ona <no-reply@ona.com> * refactor: move feed enhancement into UserSession Move enhancement logic from HTTP handler into UserSession so the transport layer has no knowledge of enhancement. UserSession.feed() handles refresh, enhancement, and caching in one place. - UserSession subscribes to engine updates and re-enhances eagerly - Enhancement cache tracks source identity to prevent stale results - UserSessionManager accepts config object with optional enhancer - HTTP handler simplified to just call session.feed() Co-authored-by: Ona <no-reply@ona.com> * test: add schema sync tests for arktype/JSON Schema drift Validates reference payloads against both the arktype schema (parseEnhancementResult) and the OpenRouter JSON Schema structure. Catches field additions/removals or type changes in either schema. Co-authored-by: Ona <no-reply@ona.com> * refactor: rename arktype schemas to match types Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
2026-03-05 02:01:30 +00:00
constructor(config: UserSessionManagerConfig) {
this.db = config.db
for (const provider of config.providers) {
this.providers.set(provider.sourceId, provider)
}
feat(backend): add LLM-powered feed enhancement (#58) * feat(backend): add LLM-powered feed enhancement Add enhancement harness that fills feed item slots and generates synthetic items via OpenRouter. - LLM client with 30s timeout, reusable SDK instance - Prompt builder with mini calendar and week overview - arktype schema validation + JSON Schema for structured output - Pure merge function with clock injection - Defensive fallback in feed endpoint on enhancement failure - Skips LLM call when no unfilled slots or no API key Co-authored-by: Ona <no-reply@ona.com> * refactor: move feed enhancement into UserSession Move enhancement logic from HTTP handler into UserSession so the transport layer has no knowledge of enhancement. UserSession.feed() handles refresh, enhancement, and caching in one place. - UserSession subscribes to engine updates and re-enhances eagerly - Enhancement cache tracks source identity to prevent stale results - UserSessionManager accepts config object with optional enhancer - HTTP handler simplified to just call session.feed() Co-authored-by: Ona <no-reply@ona.com> * test: add schema sync tests for arktype/JSON Schema drift Validates reference payloads against both the arktype schema (parseEnhancementResult) and the OpenRouter JSON Schema structure. Catches field additions/removals or type changes in either schema. Co-authored-by: Ona <no-reply@ona.com> * refactor: rename arktype schemas to match types Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
2026-03-05 02:01:30 +00:00
this.feedEnhancer = config.feedEnhancer ?? null
this.db = config.db
}
getProvider(sourceId: string): FeedSourceProvider | undefined {
return this.providers.get(sourceId)
}
async getOrCreate(userId: string): Promise<UserSession> {
const existing = this.sessions.get(userId)
if (existing) return existing
const inflight = this.pending.get(userId)
if (inflight) return inflight
const promise = this.createSession(userId)
this.pending.set(userId, promise)
try {
const session = await promise
// If remove() was called while we were awaiting, it clears the
// pending entry. Detect that and destroy the session immediately.
if (!this.pending.has(userId)) {
session.destroy()
throw new Error(`Session for user ${userId} was removed during creation`)
}
this.sessions.set(userId, session)
return session
} finally {
this.pending.delete(userId)
}
}
remove(userId: string): void {
const session = this.sessions.get(userId)
if (session) {
session.destroy()
this.sessions.delete(userId)
}
// Cancel any in-flight creation so getOrCreate won't store the session
this.pending.delete(userId)
}
/**
* Merges, validates, and persists a user's source config and/or enabled
* state, then invalidates the cached session.
*
* @throws {SourceNotFoundError} if the source row doesn't exist
* @throws {InvalidSourceConfigError} if the merged config fails schema validation
*/
async updateSourceConfig(
userId: string,
sourceId: string,
update: { enabled?: boolean; config?: unknown },
): Promise<void> {
const provider = this.providers.get(sourceId)
if (!provider) {
throw new SourceNotFoundError(sourceId, userId)
}
// Nothing to update
if (update.enabled === undefined && update.config === undefined) {
// Still validate existence — updateConfig would throw, but
// we can avoid the DB write entirely.
if (!(await sources(this.db, userId).find(sourceId))) {
throw new SourceNotFoundError(sourceId, userId)
}
return
}
// When config is provided, fetch existing to deep-merge before validating.
// NOTE: find + updateConfig is not atomic. A concurrent update could
// read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if
// this becomes a problem.
let mergedConfig: Record<string, unknown> | undefined
if (update.config !== undefined) {
const existing = await sources(this.db, userId).find(sourceId)
const existingConfig = (existing?.config ?? {}) as Record<string, unknown>
mergedConfig = merge({}, existingConfig, update.config)
if (provider.configSchema) {
const validated = provider.configSchema(mergedConfig)
if (validated instanceof type.errors) {
throw new InvalidSourceConfigError(sourceId, validated.summary)
}
}
}
// Throws SourceNotFoundError if the row doesn't exist
await sources(this.db, userId).updateConfig(sourceId, {
enabled: update.enabled,
config: mergedConfig,
})
// Refresh the specific source in the active session instead of
// destroying the entire session.
const session = this.sessions.get(userId)
if (session) {
if (update.enabled === false) {
session.removeSource(sourceId)
} else {
const source = await provider.feedSourceForUser(userId, mergedConfig ?? {})
session.replaceSource(sourceId, source)
}
}
}
/**
* Validates, persists, and upserts a user's source config, then
* refreshes the cached session. Unlike updateSourceConfig, this
* inserts a new row if one doesn't exist and fully replaces config
* (no merge).
*
* @throws {SourceNotFoundError} if the sourceId has no registered provider
* @throws {InvalidSourceConfigError} if config fails schema validation
*/
async upsertSourceConfig(
userId: string,
sourceId: string,
data: { enabled: boolean; config: unknown },
): Promise<void> {
const provider = this.providers.get(sourceId)
if (!provider) {
throw new SourceNotFoundError(sourceId, userId)
}
if (provider.configSchema) {
const validated = provider.configSchema(data.config)
if (validated instanceof type.errors) {
throw new InvalidSourceConfigError(sourceId, validated.summary)
}
}
await sources(this.db, userId).upsertConfig(sourceId, {
enabled: data.enabled,
config: data.config,
})
const session = this.sessions.get(userId)
if (session) {
if (!data.enabled) {
session.removeSource(sourceId)
} else {
const source = await provider.feedSourceForUser(userId, data.config)
if (session.hasSource(sourceId)) {
session.replaceSource(sourceId, source)
} else {
session.addSource(source)
}
}
}
}
/**
* Replaces a provider and updates all active sessions.
* The new provider must have the same sourceId as an existing one.
* For each active session, queries the user's source config from the DB
* and re-resolves the source. If the provider fails for a user, the
* existing source is kept.
*/
async replaceProvider(provider: FeedSourceProvider): Promise<void> {
if (!this.providers.has(provider.sourceId)) {
throw new Error(
`Cannot replace provider "${provider.sourceId}": no existing provider with that sourceId`,
)
}
this.providers.set(provider.sourceId, provider)
const updates: Promise<void>[] = []
for (const [, session] of this.sessions) {
updates.push(this.refreshSessionSource(session, provider))
}
// Also update sessions that are currently being created so they
// don't land in this.sessions with a stale source.
for (const [, pendingPromise] of this.pending) {
updates.push(
pendingPromise
.then((session) => this.refreshSessionSource(session, provider))
.catch(() => {
// Session creation itself failed — nothing to update.
}),
)
}
await Promise.all(updates)
}
/**
* Re-resolves a single source for a session by querying the user's config
* from the DB and calling the provider. If the provider fails, the existing
* source is kept.
*/
private async refreshSessionSource(
session: UserSession,
provider: FeedSourceProvider,
): Promise<void> {
if (!session.hasSource(provider.sourceId)) return
try {
const row = await sources(this.db, session.userId).find(provider.sourceId)
if (!row?.enabled) return
const newSource = await provider.feedSourceForUser(session.userId, row.config ?? {})
session.replaceSource(provider.sourceId, newSource)
} catch (err) {
console.error(
`[UserSessionManager] refreshSource("${provider.sourceId}") failed for user ${session.userId}:`,
err,
)
}
}
private async createSession(userId: string): Promise<UserSession> {
const enabledRows = await sources(this.db, userId).enabled()
const promises: Promise<FeedSource>[] = []
for (const row of enabledRows) {
const provider = this.providers.get(row.sourceId)
if (provider) {
promises.push(provider.feedSourceForUser(userId, row.config ?? {}))
}
}
if (promises.length === 0) {
return new UserSession(userId, [], this.feedEnhancer)
}
const results = await Promise.allSettled(promises)
const feedSources: FeedSource[] = []
const errors: unknown[] = []
for (const result of results) {
if (result.status === "fulfilled") {
feedSources.push(result.value)
} else {
errors.push(result.reason)
}
}
if (feedSources.length === 0 && errors.length > 0) {
throw new AggregateError(errors, "All feed source providers failed")
}
for (const error of errors) {
console.error("[UserSessionManager] Feed source provider failed:", error)
}
return new UserSession(userId, feedSources, this.feedEnhancer)
}
}