refactor: rename aris to aelis (#59)

Rename all references across the codebase: package names,
imports, source IDs, directory names, docs, and configs.

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-10 19:19:23 +00:00
committed by GitHub
parent 230116d9f7
commit 863c298bd3
201 changed files with 891 additions and 647 deletions

View File

@@ -0,0 +1,9 @@
import type { FeedSource } from "@aelis/core"
export interface FeedSourceProvider {
feedSourceForUser(userId: string): FeedSource
}
export type FeedSourceProviderFn = (userId: string) => FeedSource
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn

View File

@@ -0,0 +1,7 @@
export type {
FeedSourceProvider,
FeedSourceProviderFn,
FeedSourceProviderInput,
} from "./feed-source-provider.ts"
export { UserSession } from "./user-session.ts"
export { UserSessionManager } from "./user-session-manager.ts"

View File

@@ -0,0 +1,170 @@
import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
import { LocationSource } from "@aelis/source-location"
import { describe, expect, mock, test } from "bun:test"
import { WeatherSourceProvider } from "../weather/provider.ts"
import { UserSessionManager } from "./user-session-manager.ts"
const mockWeatherClient: WeatherKitClient = {
fetch: async () => ({}) as WeatherKitResponse,
}
describe("UserSessionManager", () => {
test("getOrCreate creates session on first call", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
expect(session).toBeDefined()
expect(session.engine).toBeDefined()
})
test("getOrCreate returns same session for same user", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-1")
expect(session1).toBe(session2)
})
test("getOrCreate returns different sessions for different users", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
expect(session1).not.toBe(session2)
})
test("each user gets independent source instances", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
const source1 = session1.getSource<LocationSource>("aelis.location")
const source2 = session2.getSource<LocationSource>("aelis.location")
expect(source1).not.toBe(source2)
})
test("remove destroys session and allows re-creation", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = manager.getOrCreate("user-1")
manager.remove("user-1")
const session2 = manager.getOrCreate("user-1")
expect(session1).not.toBe(session2)
})
test("remove is no-op for unknown user", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
expect(() => manager.remove("unknown")).not.toThrow()
})
test("accepts function providers", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("accepts object providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({
providers: [() => new LocationSource(), provider],
})
const session = manager.getOrCreate("user-1")
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("accepts mixed providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({
providers: [() => new LocationSource(), provider],
})
const session = manager.getOrCreate("user-1")
expect(session.getSource("aelis.location")).toBeDefined()
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("refresh returns feed result through session", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result).toHaveProperty("context")
expect(result).toHaveProperty("items")
expect(result).toHaveProperty("errors")
expect(result.context.time).toBeInstanceOf(Date)
})
test("location update via executeAction works", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
const source = session.getSource<LocationSource>("aelis.location")
expect(source?.lastLocation?.lat).toBe(51.5074)
})
test("subscribe receives updates after location push", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback)
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
// Wait for async update propagation
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).toHaveBeenCalled()
})
test("remove stops reactive updates", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback)
manager.remove("user-1")
// Create new session and push location — old callback should not fire
const session2 = manager.getOrCreate("user-1")
await session2.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,40 @@
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
import { UserSession } from "./user-session.ts"
export interface UserSessionManagerConfig {
providers: FeedSourceProviderInput[]
feedEnhancer?: FeedEnhancer | null
}
export class UserSessionManager {
private sessions = new Map<string, UserSession>()
private readonly providers: FeedSourceProviderInput[]
private readonly feedEnhancer: FeedEnhancer | null
constructor(config: UserSessionManagerConfig) {
this.providers = config.providers
this.feedEnhancer = config.feedEnhancer ?? null
}
getOrCreate(userId: string): UserSession {
let session = this.sessions.get(userId)
if (!session) {
const sources = this.providers.map((p) =>
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
)
session = new UserSession(sources, this.feedEnhancer)
this.sessions.set(userId, session)
}
return session
}
remove(userId: string): void {
const session = this.sessions.get(userId)
if (session) {
session.destroy()
this.sessions.delete(userId)
}
}
}

View File

@@ -0,0 +1,210 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import { LocationSource } from "@aelis/source-location"
import { describe, expect, test } from "bun:test"
import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems() {
return items
},
}
}
describe("UserSession", () => {
test("registers sources and starts engine", async () => {
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("getSource returns registered source", () => {
const location = new LocationSource()
const session = new UserSession([location])
const result = session.getSource<LocationSource>("aelis.location")
expect(result).toBe(location)
})
test("getSource returns undefined for unknown source", () => {
const session = new UserSession([createStubSource("test")])
expect(session.getSource("unknown")).toBeUndefined()
})
test("destroy stops engine and clears sources", () => {
const session = new UserSession([createStubSource("test")])
session.destroy()
expect(session.getSource("test")).toBeUndefined()
})
test("engine.executeAction routes to correct source", async () => {
const location = new LocationSource()
const session = new UserSession([location])
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5,
lng: -0.1,
accuracy: 10,
timestamp: new Date(),
})
expect(location.lastLocation).toBeDefined()
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)
})
})

View File

@@ -0,0 +1,104 @@
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aelis/core"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
export class UserSession {
readonly engine: FeedEngine
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[], enhancer?: FeedEnhancer | null) {
this.engine = new FeedEngine()
this.enhancer = enhancer ?? null
for (const source of sources) {
this.sources.set(source.id, source)
this.engine.register(source)
}
if (this.enhancer) {
this.unsubscribe = this.engine.subscribe((result) => {
this.invalidateEnhancement()
this.runEnhancement(result)
})
}
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 {
return this.sources.get(sourceId) as T | undefined
}
destroy(): void {
this.unsubscribe?.()
this.unsubscribe = null
this.engine.stop()
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()
}
}
}