2 Commits

Author SHA1 Message Date
db0c57f04b feat(backend): add FeedEngineService
Manages FeedEngine instances per user with auto-registration of
sources from FeedSourceProvider implementations.

- Add FeedSourceProvider interface
- Add FeedEngineService with providers array injection
- Update LocationService to implement FeedSourceProvider

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 23:12:48 +00:00
0db6cae82b Merge pull request #18 from kennethnym/feat/location-service
feat(backend): add LocationService
2026-01-25 19:41:26 +00:00
3 changed files with 240 additions and 1 deletions

View File

@@ -0,0 +1,162 @@
import { describe, expect, mock, test } from "bun:test"
import { LocationService } from "../location/service.ts"
import { FeedEngineService } from "./service.ts"
describe("FeedEngineService", () => {
test("engineForUser creates engine on first call", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine = service.engineForUser("user-1")
expect(engine).toBeDefined()
})
test("engineForUser returns same engine for same user", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
const engine2 = service.engineForUser("user-1")
expect(engine1).toBe(engine2)
})
test("engineForUser returns different engines for different users", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
const engine2 = service.engineForUser("user-2")
expect(engine1).not.toBe(engine2)
})
test("engineForUser registers sources from all providers", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine = service.engineForUser("user-1")
const result = await engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("engineForUser works with empty providers array", async () => {
const service = new FeedEngineService([])
const engine = service.engineForUser("user-1")
const result = await engine.refresh()
expect(result.errors).toHaveLength(0)
expect(result.items).toHaveLength(0)
})
test("refresh returns feed result", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const result = await service.refresh("user-1")
expect(result).toHaveProperty("context")
expect(result).toHaveProperty("items")
expect(result).toHaveProperty("errors")
expect(result.context.time).toBeInstanceOf(Date)
})
test("refresh uses location from LocationService", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
// Create engine first, then update location
service.engineForUser("user-1")
locationService.updateUserLocation("user-1", location)
const result = await service.refresh("user-1")
expect(result.context.location).toEqual(location)
})
test("subscribe receives updates", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
service.subscribe("user-1", callback)
// Push location to trigger update
locationService.updateUserLocation("user-1", {
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("subscribe returns unsubscribe function", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
const unsubscribe = service.subscribe("user-1", callback)
unsubscribe()
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
test("removeUser stops engine and removes it", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
service.subscribe("user-1", callback)
service.removeUser("user-1")
// Push location - should not trigger update since engine is stopped
locationService.feedSourceForUser("user-1")
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
test("removeUser allows new engine to be created", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
service.removeUser("user-1")
const engine2 = service.engineForUser("user-1")
expect(engine1).not.toBe(engine2)
})
})

View File

@@ -0,0 +1,75 @@
import { FeedEngine, type FeedResult, type FeedSource, type FeedSubscriber } from "@aris/core"
/**
* Provides a FeedSource instance for a user.
*/
export interface FeedSourceProvider {
feedSourceForUser(userId: string): FeedSource
}
/**
* Manages FeedEngine instances per user.
*
* Receives FeedSource instances from injected providers and wires them
* into per-user engines. Engines are auto-started on creation.
*/
export class FeedEngineService {
private engines = new Map<string, FeedEngine>()
constructor(private readonly providers: FeedSourceProvider[]) {}
/**
* Get or create a FeedEngine for a user.
* Automatically registers sources and starts the engine.
*/
engineForUser(userId: string): FeedEngine {
let engine = this.engines.get(userId)
if (!engine) {
engine = this.createEngine(userId)
this.engines.set(userId, engine)
}
return engine
}
/**
* Refresh a user's feed.
*/
async refresh(userId: string): Promise<FeedResult> {
const engine = this.engineForUser(userId)
return engine.refresh()
}
/**
* Subscribe to feed updates for a user.
* Returns unsubscribe function.
*/
subscribe(userId: string, callback: FeedSubscriber): () => void {
const engine = this.engineForUser(userId)
return engine.subscribe(callback)
}
/**
* Remove a user's FeedEngine.
* Stops the engine and cleans up resources.
*/
removeUser(userId: string): void {
const engine = this.engines.get(userId)
if (engine) {
engine.stop()
this.engines.delete(userId)
}
}
private createEngine(userId: string): FeedEngine {
const engine = new FeedEngine()
for (const provider of this.providers) {
const source = provider.feedSourceForUser(userId)
engine.register(source)
}
engine.start()
return engine
}
}

View File

@@ -1,11 +1,13 @@
import { LocationSource, type Location } from "@aris/source-location" import { LocationSource, type Location } from "@aris/source-location"
import type { FeedSourceProvider } from "../feed/service.ts"
import { UserNotFoundError } from "../lib/error.ts" import { UserNotFoundError } from "../lib/error.ts"
/** /**
* Manages LocationSource instances per user. * Manages LocationSource instances per user.
*/ */
export class LocationService { export class LocationService implements FeedSourceProvider {
private sources = new Map<string, LocationSource>() private sources = new Map<string, LocationSource>()
/** /**