mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
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>
This commit is contained in:
@@ -41,14 +41,13 @@ function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
|||||||
registerFeedHttpHandlers(app, {
|
registerFeedHttpHandlers(app, {
|
||||||
sessionManager,
|
sessionManager,
|
||||||
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
||||||
feedEnhancer: null,
|
|
||||||
})
|
})
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("GET /api/feed", () => {
|
describe("GET /api/feed", () => {
|
||||||
test("returns 401 without auth", async () => {
|
test("returns 401 without auth", async () => {
|
||||||
const manager = new UserSessionManager([])
|
const manager = new UserSessionManager({ providers: [] })
|
||||||
const app = buildTestApp(manager)
|
const app = buildTestApp(manager)
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
const res = await app.request("/api/feed")
|
||||||
@@ -66,7 +65,9 @@ describe("GET /api/feed", () => {
|
|||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => createStubSource("test", items)],
|
||||||
|
})
|
||||||
const app = buildTestApp(manager, "user-1")
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
// Prime the cache
|
// Prime the cache
|
||||||
@@ -96,7 +97,9 @@ describe("GET /api/feed", () => {
|
|||||||
data: { fresh: true },
|
data: { fresh: true },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => createStubSource("test", items)],
|
||||||
|
})
|
||||||
const app = buildTestApp(manager, "user-1")
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||||
@@ -110,66 +113,6 @@ describe("GET /api/feed", () => {
|
|||||||
expect(body.errors).toHaveLength(0)
|
expect(body.errors).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns enhanced items when feedEnhancer is provided", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
|
||||||
|
|
||||||
const enhancer = async (feedItems: FeedItem[]) =>
|
|
||||||
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
|
||||||
|
|
||||||
const app = new Hono()
|
|
||||||
registerFeedHttpHandlers(app, {
|
|
||||||
sessionManager: manager,
|
|
||||||
authSessionMiddleware: mockAuthSessionMiddleware("user-1"),
|
|
||||||
feedEnhancer: enhancer,
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.data.enhanced).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back to raw items when feedEnhancer throws", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
|
||||||
|
|
||||||
const enhancer = async () => {
|
|
||||||
throw new Error("enhancement exploded")
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = new Hono()
|
|
||||||
registerFeedHttpHandlers(app, {
|
|
||||||
sessionManager: manager,
|
|
||||||
authSessionMiddleware: mockAuthSessionMiddleware("user-1"),
|
|
||||||
feedEnhancer: enhancer,
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.id).toBe("item-1")
|
|
||||||
expect(body.items[0]!.data.value).toBe(42)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("serializes source errors as message strings", async () => {
|
test("serializes source errors as message strings", async () => {
|
||||||
const failingSource: FeedSource = {
|
const failingSource: FeedSource = {
|
||||||
id: "failing",
|
id: "failing",
|
||||||
@@ -186,7 +129,7 @@ describe("GET /api/feed", () => {
|
|||||||
throw new Error("connection timeout")
|
throw new Error("connection timeout")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const manager = new UserSessionManager([() => failingSource])
|
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
||||||
const app = buildTestApp(manager, "user-1")
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
const res = await app.request("/api/feed")
|
||||||
|
|||||||
@@ -3,29 +3,25 @@ import type { Context, Hono } from "hono"
|
|||||||
import { createMiddleware } from "hono/factory"
|
import { createMiddleware } from "hono/factory"
|
||||||
|
|
||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
type Env = {
|
type Env = {
|
||||||
Variables: {
|
Variables: {
|
||||||
sessionManager: UserSessionManager
|
sessionManager: UserSessionManager
|
||||||
feedEnhancer: FeedEnhancer | null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeedHttpHandlersDeps {
|
interface FeedHttpHandlersDeps {
|
||||||
sessionManager: UserSessionManager
|
sessionManager: UserSessionManager
|
||||||
authSessionMiddleware: AuthSessionMiddleware
|
authSessionMiddleware: AuthSessionMiddleware
|
||||||
feedEnhancer: FeedEnhancer | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerFeedHttpHandlers(
|
export function registerFeedHttpHandlers(
|
||||||
app: Hono,
|
app: Hono,
|
||||||
{ sessionManager, authSessionMiddleware, feedEnhancer }: FeedHttpHandlersDeps,
|
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
||||||
) {
|
) {
|
||||||
const inject = createMiddleware<Env>(async (c, next) => {
|
const inject = createMiddleware<Env>(async (c, next) => {
|
||||||
c.set("sessionManager", sessionManager)
|
c.set("sessionManager", sessionManager)
|
||||||
c.set("feedEnhancer", feedEnhancer)
|
|
||||||
await next()
|
await next()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -37,20 +33,10 @@ async function handleGetFeed(c: Context<Env>) {
|
|||||||
const sessionManager = c.get("sessionManager")
|
const sessionManager = c.get("sessionManager")
|
||||||
const session = sessionManager.getOrCreate(user.id)
|
const session = sessionManager.getOrCreate(user.id)
|
||||||
|
|
||||||
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
|
const feed = await session.feed()
|
||||||
|
|
||||||
let items = feed.items
|
|
||||||
const enhance = c.get("feedEnhancer")
|
|
||||||
if (enhance) {
|
|
||||||
try {
|
|
||||||
items = await enhance(feed.items)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[enhancement] Unexpected error, returning unenhanced feed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
items,
|
items: feed.items,
|
||||||
errors: feed.errors.map((e) => ({
|
errors: feed.errors.map((e) => ({
|
||||||
sourceId: e.sourceId,
|
sourceId: e.sourceId,
|
||||||
error: e.error.message,
|
error: e.error.message,
|
||||||
|
|||||||
@@ -11,18 +11,6 @@ import { UserSessionManager } from "./session/index.ts"
|
|||||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const sessionManager = new UserSessionManager([
|
|
||||||
() => new LocationSource(),
|
|
||||||
new WeatherSourceProvider({
|
|
||||||
credentials: {
|
|
||||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
|
||||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
|
||||||
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
|
||||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
||||||
const feedEnhancer = openrouterApiKey
|
const feedEnhancer = openrouterApiKey
|
||||||
? createFeedEnhancer({
|
? createFeedEnhancer({
|
||||||
@@ -36,6 +24,21 @@ function main() {
|
|||||||
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
|
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionManager = new UserSessionManager({
|
||||||
|
providers: [
|
||||||
|
() => new LocationSource(),
|
||||||
|
new WeatherSourceProvider({
|
||||||
|
credentials: {
|
||||||
|
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||||
|
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||||
|
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||||||
|
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
feedEnhancer,
|
||||||
|
})
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
@@ -44,7 +47,6 @@ function main() {
|
|||||||
registerFeedHttpHandlers(app, {
|
registerFeedHttpHandlers(app, {
|
||||||
sessionManager,
|
sessionManager,
|
||||||
authSessionMiddleware: requireSession,
|
authSessionMiddleware: requireSession,
|
||||||
feedEnhancer,
|
|
||||||
})
|
})
|
||||||
registerLocationHttpHandlers(app, { sessionManager })
|
registerLocationHttpHandlers(app, { sessionManager })
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const mockWeatherClient: WeatherKitClient = {
|
|||||||
|
|
||||||
describe("UserSessionManager", () => {
|
describe("UserSessionManager", () => {
|
||||||
test("getOrCreate creates session on first call", () => {
|
test("getOrCreate creates session on first call", () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns same session for same user", () => {
|
test("getOrCreate returns same session for same user", () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-1")
|
const session2 = manager.getOrCreate("user-1")
|
||||||
@@ -30,7 +30,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns different sessions for different users", () => {
|
test("getOrCreate returns different sessions for different users", () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-2")
|
const session2 = manager.getOrCreate("user-2")
|
||||||
@@ -39,7 +39,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("each user gets independent source instances", () => {
|
test("each user gets independent source instances", () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-2")
|
const session2 = manager.getOrCreate("user-2")
|
||||||
@@ -51,7 +51,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove destroys session and allows re-creation", () => {
|
test("remove destroys session and allows re-creation", () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
manager.remove("user-1")
|
manager.remove("user-1")
|
||||||
@@ -61,13 +61,13 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove is no-op for unknown user", () => {
|
test("remove is no-op for unknown user", () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
expect(() => manager.remove("unknown")).not.toThrow()
|
expect(() => manager.remove("unknown")).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("accepts function providers", async () => {
|
test("accepts function providers", async () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
@@ -77,7 +77,9 @@ describe("UserSessionManager", () => {
|
|||||||
|
|
||||||
test("accepts object providers", () => {
|
test("accepts object providers", () => {
|
||||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => new LocationSource(), provider],
|
||||||
|
})
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
@@ -86,7 +88,9 @@ describe("UserSessionManager", () => {
|
|||||||
|
|
||||||
test("accepts mixed providers", () => {
|
test("accepts mixed providers", () => {
|
||||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => new LocationSource(), provider],
|
||||||
|
})
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
@@ -95,7 +99,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("refresh returns feed result through session", async () => {
|
test("refresh returns feed result through session", async () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
@@ -107,7 +111,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("location update via executeAction works", async () => {
|
test("location update via executeAction works", async () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
await session.engine.executeAction("aris.location", "update-location", {
|
await session.engine.executeAction("aris.location", "update-location", {
|
||||||
@@ -122,7 +126,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("subscribe receives updates after location push", async () => {
|
test("subscribe receives updates after location push", async () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
@@ -142,7 +146,7 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove stops reactive updates", async () => {
|
test("remove stops reactive updates", async () => {
|
||||||
const manager = new UserSessionManager([() => new LocationSource()])
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
||||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
|
export interface UserSessionManagerConfig {
|
||||||
|
providers: FeedSourceProviderInput[]
|
||||||
|
feedEnhancer?: FeedEnhancer | null
|
||||||
|
}
|
||||||
|
|
||||||
export class UserSessionManager {
|
export class UserSessionManager {
|
||||||
private sessions = new Map<string, UserSession>()
|
private sessions = new Map<string, UserSession>()
|
||||||
private readonly providers: FeedSourceProviderInput[]
|
private readonly providers: FeedSourceProviderInput[]
|
||||||
|
private readonly feedEnhancer: FeedEnhancer | null
|
||||||
|
|
||||||
constructor(providers: FeedSourceProviderInput[]) {
|
constructor(config: UserSessionManagerConfig) {
|
||||||
this.providers = providers
|
this.providers = config.providers
|
||||||
|
this.feedEnhancer = config.feedEnhancer ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrCreate(userId: string): UserSession {
|
getOrCreate(userId: string): UserSession {
|
||||||
@@ -16,7 +24,7 @@ export class UserSessionManager {
|
|||||||
const sources = this.providers.map((p) =>
|
const sources = this.providers.map((p) =>
|
||||||
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
||||||
)
|
)
|
||||||
session = new UserSession(sources)
|
session = new UserSession(sources, this.feedEnhancer)
|
||||||
this.sessions.set(userId, session)
|
this.sessions.set(userId, session)
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { LocationSource } from "@aris/source-location"
|
import { LocationSource } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
function createStubSource(id: string): FeedSource {
|
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
@@ -18,7 +18,7 @@ function createStubSource(id: string): FeedSource {
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
async fetchItems() {
|
||||||
return []
|
return items
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,3 +70,141 @@ describe("UserSession", () => {
|
|||||||
expect(location.lastLocation!.lat).toBe(51.5)
|
expect(location.lastLocation!.lat).toBe(51.5)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("UserSession.feed", () => {
|
||||||
|
test("returns feed items without enhancer", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { value: 42 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const session = new UserSession([createStubSource("test", items)])
|
||||||
|
|
||||||
|
const result = await session.feed()
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0]!.id).toBe("item-1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns enhanced items when enhancer is provided", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { value: 42 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const enhancer = async (feedItems: FeedItem[]) =>
|
||||||
|
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
||||||
|
|
||||||
|
const session = new UserSession([createStubSource("test", items)], enhancer)
|
||||||
|
|
||||||
|
const result = await session.feed()
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0]!.data.enhanced).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caches enhanced items on subsequent calls", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { value: 42 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
let enhancerCallCount = 0
|
||||||
|
const enhancer = async (feedItems: FeedItem[]) => {
|
||||||
|
enhancerCallCount++
|
||||||
|
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new UserSession([createStubSource("test", items)], enhancer)
|
||||||
|
|
||||||
|
const result1 = await session.feed()
|
||||||
|
expect(result1.items[0]!.data.enhanced).toBe(true)
|
||||||
|
expect(enhancerCallCount).toBe(1)
|
||||||
|
|
||||||
|
const result2 = await session.feed()
|
||||||
|
expect(result2.items[0]!.data.enhanced).toBe(true)
|
||||||
|
expect(enhancerCallCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("re-enhances after engine refresh with new data", async () => {
|
||||||
|
let currentItems: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { version: 1 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const source = createStubSource("test", currentItems)
|
||||||
|
// Make fetchItems dynamic so refresh returns new data
|
||||||
|
source.fetchItems = async () => currentItems
|
||||||
|
|
||||||
|
const enhancedVersions: number[] = []
|
||||||
|
const enhancer = async (feedItems: FeedItem[]) => {
|
||||||
|
const version = feedItems[0]!.data.version as number
|
||||||
|
enhancedVersions.push(version)
|
||||||
|
return feedItems.map((item) => ({
|
||||||
|
...item,
|
||||||
|
data: { ...item.data, enhanced: true },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new UserSession([source], enhancer)
|
||||||
|
|
||||||
|
// First feed triggers refresh + enhancement
|
||||||
|
const result1 = await session.feed()
|
||||||
|
expect(result1.items[0]!.data.version).toBe(1)
|
||||||
|
expect(result1.items[0]!.data.enhanced).toBe(true)
|
||||||
|
|
||||||
|
// Update source data and trigger engine refresh
|
||||||
|
currentItems = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
||||||
|
data: { version: 2 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
// Wait for subscriber-triggered background enhancement
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
|
||||||
|
// feed() should now serve re-enhanced items with version 2
|
||||||
|
const result2 = await session.feed()
|
||||||
|
expect(result2.items[0]!.data.version).toBe(2)
|
||||||
|
expect(result2.items[0]!.data.enhanced).toBe(true)
|
||||||
|
expect(enhancedVersions).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to unenhanced items when enhancer throws", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { value: 42 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const enhancer = async () => {
|
||||||
|
throw new Error("enhancement exploded")
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new UserSession([createStubSource("test", items)], enhancer)
|
||||||
|
|
||||||
|
const result = await session.feed()
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0]!.id).toBe("item-1")
|
||||||
|
expect(result.items[0]!.data.value).toBe(42)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,24 +1,104 @@
|
|||||||
import { FeedEngine, type FeedSource } from "@aris/core"
|
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aris/core"
|
||||||
|
|
||||||
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
||||||
|
|
||||||
export class UserSession {
|
export class UserSession {
|
||||||
readonly engine: FeedEngine
|
readonly engine: FeedEngine
|
||||||
private sources = new Map<string, FeedSource>()
|
private sources = new Map<string, FeedSource>()
|
||||||
|
private readonly enhancer: FeedEnhancer | null
|
||||||
|
private enhancedItems: FeedItem[] | null = null
|
||||||
|
/** The FeedResult that enhancedItems was derived from. */
|
||||||
|
private enhancedSource: FeedResult | null = null
|
||||||
|
private enhancingPromise: Promise<void> | null = null
|
||||||
|
private unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
constructor(sources: FeedSource[]) {
|
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
||||||
this.engine = new FeedEngine()
|
this.engine = new FeedEngine()
|
||||||
|
this.enhancer = enhancer ?? null
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
this.sources.set(source.id, source)
|
this.sources.set(source.id, source)
|
||||||
this.engine.register(source)
|
this.engine.register(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.enhancer) {
|
||||||
|
this.unsubscribe = this.engine.subscribe((result) => {
|
||||||
|
this.invalidateEnhancement()
|
||||||
|
this.runEnhancement(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
this.engine.start()
|
this.engine.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current feed, refreshing if the engine cache expired.
|
||||||
|
* Enhancement runs eagerly on engine updates; this method awaits
|
||||||
|
* any in-flight enhancement or triggers one if needed.
|
||||||
|
*/
|
||||||
|
async feed(): Promise<FeedResult> {
|
||||||
|
const cached = this.engine.lastFeed()
|
||||||
|
const result = cached ?? (await this.engine.refresh())
|
||||||
|
|
||||||
|
if (!this.enhancer) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for any in-flight background enhancement to finish
|
||||||
|
if (this.enhancingPromise) {
|
||||||
|
await this.enhancingPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve cached enhancement only if it matches the current engine result
|
||||||
|
if (this.enhancedItems && this.enhancedSource === result) {
|
||||||
|
return { ...result, items: this.enhancedItems }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale or missing — re-enhance
|
||||||
|
await this.runEnhancement(result)
|
||||||
|
|
||||||
|
if (this.enhancedItems) {
|
||||||
|
return { ...result, items: this.enhancedItems }
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
||||||
return this.sources.get(sourceId) as T | undefined
|
return this.sources.get(sourceId) as T | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
this.unsubscribe?.()
|
||||||
|
this.unsubscribe = null
|
||||||
this.engine.stop()
|
this.engine.stop()
|
||||||
this.sources.clear()
|
this.sources.clear()
|
||||||
|
this.invalidateEnhancement()
|
||||||
|
this.enhancingPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private invalidateEnhancement(): void {
|
||||||
|
this.enhancedItems = null
|
||||||
|
this.enhancedSource = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private runEnhancement(result: FeedResult): Promise<void> {
|
||||||
|
const promise = this.enhance(result)
|
||||||
|
this.enhancingPromise = promise
|
||||||
|
promise.finally(() => {
|
||||||
|
if (this.enhancingPromise === promise) {
|
||||||
|
this.enhancingPromise = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enhance(result: FeedResult): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.enhancedItems = await this.enhancer!(result.items)
|
||||||
|
this.enhancedSource = result
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[enhancement] Unexpected error:", err)
|
||||||
|
this.invalidateEnhancement()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ function partsEqual(a: unknown, b: unknown): boolean {
|
|||||||
const bKeys = Object.keys(b)
|
const bKeys = Object.keys(b)
|
||||||
if (aKeys.length !== bKeys.length) return false
|
if (aKeys.length !== bKeys.length) return false
|
||||||
return aKeys.every((key) =>
|
return aKeys.every((key) =>
|
||||||
partsEqual(
|
partsEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
|
||||||
(a as Record<string, unknown>)[key],
|
|
||||||
(b as Record<string, unknown>)[key],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user