2026-03-15 22:57:19 +00:00
|
|
|
import type { FeedSource } from "@aelis/core"
|
|
|
|
|
|
2026-03-22 16:28:19 +00:00
|
|
|
import type { Database } from "../db/index.ts"
|
2026-03-05 02:01:30 +00:00
|
|
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
2026-03-19 23:32:29 +00:00
|
|
|
import type { FeedSourceProvider } from "./feed-source-provider.ts"
|
2026-02-22 20:59:19 +00:00
|
|
|
|
2026-03-22 16:28:19 +00:00
|
|
|
import { sources } from "../sources/user-sources.ts"
|
2026-02-18 00:41:20 +00:00
|
|
|
import { UserSession } from "./user-session.ts"
|
|
|
|
|
|
2026-03-05 02:01:30 +00:00
|
|
|
export interface UserSessionManagerConfig {
|
2026-03-22 16:28:19 +00:00
|
|
|
db: Database
|
2026-03-19 23:32:29 +00:00
|
|
|
providers: FeedSourceProvider[]
|
2026-03-05 02:01:30 +00:00
|
|
|
feedEnhancer?: FeedEnhancer | null
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 00:41:20 +00:00
|
|
|
export class UserSessionManager {
|
2026-03-22 00:13:22 +00:00
|
|
|
private sessions = new Map<string, UserSession>()
|
2026-03-15 22:57:19 +00:00
|
|
|
private pending = new Map<string, Promise<UserSession>>()
|
2026-03-22 16:28:19 +00:00
|
|
|
private readonly db: Database
|
2026-03-19 23:32:29 +00:00
|
|
|
private readonly providers = new Map<string, FeedSourceProvider>()
|
2026-03-05 02:01:30 +00:00
|
|
|
private readonly feedEnhancer: FeedEnhancer | null
|
2026-02-18 00:41:20 +00:00
|
|
|
|
2026-03-05 02:01:30 +00:00
|
|
|
constructor(config: UserSessionManagerConfig) {
|
2026-03-22 16:28:19 +00:00
|
|
|
this.db = config.db
|
2026-03-19 23:32:29 +00:00
|
|
|
for (const provider of config.providers) {
|
|
|
|
|
this.providers.set(provider.sourceId, provider)
|
|
|
|
|
}
|
2026-03-05 02:01:30 +00:00
|
|
|
this.feedEnhancer = config.feedEnhancer ?? null
|
2026-02-18 00:41:20 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 19:01:43 +00:00
|
|
|
getProvider(sourceId: string): FeedSourceProvider | undefined {
|
|
|
|
|
return this.providers.get(sourceId)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 22:57:19 +00:00
|
|
|
async getOrCreate(userId: string): Promise<UserSession> {
|
|
|
|
|
const existing = this.sessions.get(userId)
|
2026-03-22 00:13:22 +00:00
|
|
|
if (existing) return existing
|
2026-03-15 22:57:19 +00:00
|
|
|
|
|
|
|
|
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`)
|
|
|
|
|
}
|
2026-03-22 00:13:22 +00:00
|
|
|
this.sessions.set(userId, session)
|
2026-03-15 22:57:19 +00:00
|
|
|
return session
|
|
|
|
|
} finally {
|
|
|
|
|
this.pending.delete(userId)
|
2026-02-18 00:41:20 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
remove(userId: string): void {
|
2026-03-22 00:13:22 +00:00
|
|
|
const session = this.sessions.get(userId)
|
|
|
|
|
if (session) {
|
|
|
|
|
session.destroy()
|
2026-02-18 00:41:20 +00:00
|
|
|
this.sessions.delete(userId)
|
|
|
|
|
}
|
2026-03-15 22:57:19 +00:00
|
|
|
// Cancel any in-flight creation so getOrCreate won't store the session
|
|
|
|
|
this.pending.delete(userId)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 23:32:29 +00:00
|
|
|
/**
|
|
|
|
|
* Replaces a provider and updates all active sessions.
|
|
|
|
|
* The new provider must have the same sourceId as an existing one.
|
2026-03-22 16:28:19 +00:00
|
|
|
* 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.
|
2026-03-19 23:32:29 +00:00
|
|
|
*/
|
|
|
|
|
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>[] = []
|
|
|
|
|
|
2026-03-22 00:13:22 +00:00
|
|
|
for (const [, session] of this.sessions) {
|
2026-03-22 16:28:19 +00:00
|
|
|
updates.push(this.refreshSessionSource(session, provider))
|
2026-03-19 23:32:29 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also update sessions that are currently being created so they
|
|
|
|
|
// don't land in this.sessions with a stale source.
|
2026-03-22 00:13:22 +00:00
|
|
|
for (const [, pendingPromise] of this.pending) {
|
2026-03-19 23:32:29 +00:00
|
|
|
updates.push(
|
|
|
|
|
pendingPromise
|
2026-03-22 16:28:19 +00:00
|
|
|
.then((session) => this.refreshSessionSource(session, provider))
|
2026-03-19 23:32:29 +00:00
|
|
|
.catch(() => {
|
|
|
|
|
// Session creation itself failed — nothing to update.
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Promise.all(updates)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 16:28:19 +00:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 22:57:19 +00:00
|
|
|
private async createSession(userId: string): Promise<UserSession> {
|
2026-03-22 16:28:19 +00:00
|
|
|
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)
|
2026-03-15 22:57:19 +00:00
|
|
|
|
2026-03-22 16:28:19 +00:00
|
|
|
const feedSources: FeedSource[] = []
|
2026-03-15 22:57:19 +00:00
|
|
|
const errors: unknown[] = []
|
|
|
|
|
|
|
|
|
|
for (const result of results) {
|
|
|
|
|
if (result.status === "fulfilled") {
|
2026-03-22 16:28:19 +00:00
|
|
|
feedSources.push(result.value)
|
2026-03-15 22:57:19 +00:00
|
|
|
} else {
|
|
|
|
|
errors.push(result.reason)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 16:28:19 +00:00
|
|
|
if (feedSources.length === 0 && errors.length > 0) {
|
2026-03-15 22:57:19 +00:00
|
|
|
throw new AggregateError(errors, "All feed source providers failed")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const error of errors) {
|
|
|
|
|
console.error("[UserSessionManager] Feed source provider failed:", error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 16:28:19 +00:00
|
|
|
return new UserSession(userId, feedSources, this.feedEnhancer)
|
2026-02-18 00:41:20 +00:00
|
|
|
}
|
|
|
|
|
}
|