mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
14 Commits
dev/ios-si
...
feat/feed-
| Author | SHA1 | Date | |
|---|---|---|---|
|
941acb826c
|
|||
| 3d492a5d56 | |||
|
08dd437952
|
|||
| 2fc20759dd | |||
|
963bf073d1
|
|||
| c0b3db0e11 | |||
|
ca4a337dcd
|
|||
| 769e2d4eb0 | |||
|
5e9094710d
|
|||
|
5556f3fbf9
|
|||
|
0176979925
|
|||
|
971aba0932
|
|||
|
68e319e4b8
|
|||
| c042af88f3 |
@@ -13,8 +13,6 @@
|
||||
"@aris/source-location": "workspace:*",
|
||||
"@aris/source-tfl": "workspace:*",
|
||||
"@aris/source-weatherkit": "workspace:*",
|
||||
"@hono/trpc-server": "^0.3",
|
||||
"@trpc/server": "^11",
|
||||
"arktype": "^2.1.29",
|
||||
"better-auth": "^1",
|
||||
"hono": "^4",
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { Context, Next } from "hono"
|
||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||
|
||||
import type { AuthSession, AuthUser } from "./session.ts"
|
||||
|
||||
import { auth } from "./index.ts"
|
||||
|
||||
type SessionUser = typeof auth.$Infer.Session.user
|
||||
type Session = typeof auth.$Infer.Session.session
|
||||
|
||||
export interface SessionVariables {
|
||||
user: SessionUser | null
|
||||
session: Session | null
|
||||
user: AuthUser | null
|
||||
session: AuthSession | null
|
||||
}
|
||||
|
||||
export type AuthSessionEnv = { Variables: SessionVariables }
|
||||
|
||||
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap extends SessionVariables {}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +55,22 @@ export async function requireSession(c: Context, next: Next): Promise<Response |
|
||||
*/
|
||||
export async function getSessionFromHeaders(
|
||||
headers: Headers,
|
||||
): Promise<{ user: SessionUser; session: Session } | null> {
|
||||
): Promise<{ user: AuthUser; session: AuthSession } | null> {
|
||||
const session = await auth.api.getSession({ headers })
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only middleware that injects a fake user and session.
|
||||
* Pass userId to simulate an authenticated request, or omit to get 401.
|
||||
*/
|
||||
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
|
||||
return async (c: Context, next: Next): Promise<Response | void> => {
|
||||
if (!userId) {
|
||||
return c.json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
c.set("user", { id: userId } as AuthUser)
|
||||
c.set("session", { id: "mock-session" } as AuthSession)
|
||||
await next()
|
||||
}
|
||||
}
|
||||
|
||||
4
apps/aris-backend/src/auth/session.ts
Normal file
4
apps/aris-backend/src/auth/session.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { auth } from "./index.ts"
|
||||
|
||||
export type AuthUser = typeof auth.$Infer.Session.user
|
||||
export type AuthSession = typeof auth.$Infer.Session.session
|
||||
140
apps/aris-backend/src/feed/http.test.ts
Normal file
140
apps/aris-backend/src/feed/http.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import { UserSessionManager } from "../session/index.ts"
|
||||
import { registerFeedHttpHandlers } from "./http.ts"
|
||||
|
||||
interface FeedResponse {
|
||||
items: Array<{
|
||||
id: string
|
||||
type: string
|
||||
priority: number
|
||||
timestamp: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
errors: Array<{ sourceId: string; error: string }>
|
||||
}
|
||||
|
||||
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<Partial<Context> | null> {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return items
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
||||
const app = new Hono()
|
||||
registerFeedHttpHandlers(app, {
|
||||
sessionManager,
|
||||
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
describe("GET /api/feed", () => {
|
||||
test("returns 401 without auth", async () => {
|
||||
const manager = new UserSessionManager([])
|
||||
const app = buildTestApp(manager)
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("returns cached feed when available", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
type: "test",
|
||||
priority: 0.8,
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// Prime the cache
|
||||
const session = manager.getOrCreate("user-1")
|
||||
await session.engine.refresh()
|
||||
expect(session.engine.lastFeed()).not.toBeNull()
|
||||
|
||||
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]!.type).toBe("test")
|
||||
expect(body.items[0]!.priority).toBe(0.8)
|
||||
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
||||
expect(body.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("forces refresh when no cached feed", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "fresh-1",
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
||||
data: { fresh: true },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||
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("fresh-1")
|
||||
expect(body.items[0]!.data.fresh).toBe(true)
|
||||
expect(body.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("serializes source errors as message strings", async () => {
|
||||
const failingSource: FeedSource = {
|
||||
id: "failing",
|
||||
async listActions() {
|
||||
return {}
|
||||
},
|
||||
async executeAction() {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext() {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
throw new Error("connection timeout")
|
||||
},
|
||||
}
|
||||
const manager = new UserSessionManager([() => failingSource])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(0)
|
||||
expect(body.errors).toHaveLength(1)
|
||||
expect(body.errors[0]!.sourceId).toBe("failing")
|
||||
expect(body.errors[0]!.error).toBe("connection timeout")
|
||||
})
|
||||
})
|
||||
41
apps/aris-backend/src/feed/http.ts
Normal file
41
apps/aris-backend/src/feed/http.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Context, Hono } from "hono"
|
||||
|
||||
import { createMiddleware } from "hono/factory"
|
||||
|
||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
|
||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||
|
||||
interface FeedHttpHandlersDeps {
|
||||
sessionManager: UserSessionManager
|
||||
authSessionMiddleware: AuthSessionMiddleware
|
||||
}
|
||||
|
||||
export function registerFeedHttpHandlers(
|
||||
app: Hono,
|
||||
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
||||
) {
|
||||
const inject = createMiddleware<Env>(async (c, next) => {
|
||||
c.set("sessionManager", sessionManager)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
||||
}
|
||||
|
||||
async function handleGetFeed(c: Context<Env>) {
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
|
||||
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
|
||||
|
||||
return c.json({
|
||||
items: feed.items,
|
||||
errors: feed.errors.map((e) => ({
|
||||
sourceId: e.sourceId,
|
||||
error: e.error.message,
|
||||
})),
|
||||
})
|
||||
}
|
||||
56
apps/aris-backend/src/location/http.ts
Normal file
56
apps/aris-backend/src/location/http.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Context, Hono } from "hono"
|
||||
|
||||
import { type } from "arktype"
|
||||
import { createMiddleware } from "hono/factory"
|
||||
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
|
||||
import { requireSession } from "../auth/session-middleware.ts"
|
||||
|
||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||
|
||||
const locationInput = type({
|
||||
lat: "number",
|
||||
lng: "number",
|
||||
accuracy: "number",
|
||||
timestamp: "string.date.iso",
|
||||
})
|
||||
|
||||
export function registerLocationHttpHandlers(
|
||||
app: Hono,
|
||||
{ sessionManager }: { sessionManager: UserSessionManager },
|
||||
) {
|
||||
const inject = createMiddleware<Env>(async (c, next) => {
|
||||
c.set("sessionManager", sessionManager)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.post("/api/location", inject, requireSession, handleUpdateLocation)
|
||||
}
|
||||
|
||||
async function handleUpdateLocation(c: Context<Env>) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON" }, 400)
|
||||
}
|
||||
|
||||
const result = locationInput(body)
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
return c.json({ error: result.summary }, 400)
|
||||
}
|
||||
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
await session.engine.executeAction("aris.location", "update-location", {
|
||||
lat: result.lat,
|
||||
lng: result.lng,
|
||||
accuracy: result.accuracy,
|
||||
timestamp: new Date(result.timestamp),
|
||||
})
|
||||
|
||||
return c.body(null, 204)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { type } from "arktype"
|
||||
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
import type { TRPC } from "../trpc/router.ts"
|
||||
|
||||
const locationInput = type({
|
||||
lat: "number",
|
||||
lng: "number",
|
||||
accuracy: "number",
|
||||
timestamp: "Date",
|
||||
})
|
||||
|
||||
export function createLocationRouter(
|
||||
t: TRPC,
|
||||
{ sessionManager }: { sessionManager: UserSessionManager },
|
||||
) {
|
||||
return t.router({
|
||||
update: t.procedure.input(locationInput).mutation(async ({ input, ctx }) => {
|
||||
const session = sessionManager.getOrCreate(ctx.user.id)
|
||||
await session.engine.executeAction("aris.location", "update-location", {
|
||||
lat: input.lat,
|
||||
lng: input.lng,
|
||||
accuracy: input.accuracy,
|
||||
timestamp: input.timestamp,
|
||||
})
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { LocationSource } from "@aris/source-location"
|
||||
import { trpcServer } from "@hono/trpc-server"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { registerAuthHandlers } from "./auth/http.ts"
|
||||
import { requireSession } from "./auth/session-middleware.ts"
|
||||
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||
import { UserSessionManager } from "./session/index.ts"
|
||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||
import { createContext } from "./trpc/context.ts"
|
||||
import { createTRPCRouter } from "./trpc/router.ts"
|
||||
|
||||
function main() {
|
||||
const sessionManager = new UserSessionManager([
|
||||
@@ -21,21 +21,13 @@ function main() {
|
||||
}),
|
||||
])
|
||||
|
||||
const trpcRouter = createTRPCRouter({ sessionManager })
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||
|
||||
registerAuthHandlers(app)
|
||||
|
||||
app.use(
|
||||
"/trpc/*",
|
||||
trpcServer({
|
||||
router: trpcRouter,
|
||||
createContext,
|
||||
}),
|
||||
)
|
||||
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
|
||||
registerLocationHttpHandlers(app, { sessionManager })
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
|
||||
|
||||
import { LocationSource } from "@aris/source-location"
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { WeatherSourceProvider } from "../weather/provider.ts"
|
||||
import { UserSessionManager } from "./user-session-manager.ts"
|
||||
|
||||
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
|
||||
|
||||
const mockWeatherClient: WeatherKitClient = {
|
||||
fetch: async () => ({}) as WeatherKitResponse,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||
|
||||
import { UserSession } from "./user-session.ts"
|
||||
|
||||
export class UserSessionManager {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"
|
||||
|
||||
import { auth } from "../auth/index.ts"
|
||||
|
||||
export async function createContext(opts: FetchCreateContextFnOptions) {
|
||||
const session = await auth.api.getSession({ headers: opts.req.headers })
|
||||
|
||||
return {
|
||||
user: session?.user ?? null,
|
||||
session: session?.session ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>
|
||||
@@ -1,44 +0,0 @@
|
||||
import { initTRPC, TRPCError } from "@trpc/server"
|
||||
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
import type { Context } from "./context.ts"
|
||||
|
||||
import { createLocationRouter } from "../location/router.ts"
|
||||
|
||||
export type TRPC = ReturnType<typeof createTRPC>
|
||||
|
||||
export interface TRPCRouterDeps {
|
||||
sessionManager: UserSessionManager
|
||||
}
|
||||
|
||||
export function createTRPCRouter({ sessionManager }: TRPCRouterDeps) {
|
||||
const t = createTRPC()
|
||||
|
||||
return t.router({
|
||||
location: createLocationRouter(t, { sessionManager }),
|
||||
})
|
||||
}
|
||||
|
||||
export type TRPCRouter = ReturnType<typeof createTRPCRouter>
|
||||
|
||||
function createTRPC() {
|
||||
const t = initTRPC.context<Context>().create()
|
||||
|
||||
const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user || !ctx.session) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" })
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
user: ctx.user,
|
||||
session: ctx.session,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
router: t.router,
|
||||
procedure: t.procedure.use(isAuthed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,97 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-font"
|
||||
[
|
||||
"expo-font",
|
||||
{
|
||||
"android": {
|
||||
"fonts": [
|
||||
{
|
||||
"fontFamily": "Inter",
|
||||
"fontDefinitions": [
|
||||
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
|
||||
{ "path": "./assets/fonts/Inter_100Thin_Italic.ttf", "weight": 100, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
|
||||
{ "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
|
||||
{ "path": "./assets/fonts/Inter_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
|
||||
{ "path": "./assets/fonts/Inter_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
|
||||
{ "path": "./assets/fonts/Inter_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
|
||||
{ "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
|
||||
{ "path": "./assets/fonts/Inter_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
|
||||
{ "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
|
||||
{ "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "Source Serif 4",
|
||||
"fontDefinitions": [
|
||||
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"fonts": [
|
||||
"./assets/fonts/Inter_100Thin.ttf",
|
||||
"./assets/fonts/Inter_100Thin_Italic.ttf",
|
||||
"./assets/fonts/Inter_200ExtraLight.ttf",
|
||||
"./assets/fonts/Inter_200ExtraLight_Italic.ttf",
|
||||
"./assets/fonts/Inter_300Light.ttf",
|
||||
"./assets/fonts/Inter_300Light_Italic.ttf",
|
||||
"./assets/fonts/Inter_400Regular.ttf",
|
||||
"./assets/fonts/Inter_400Regular_Italic.ttf",
|
||||
"./assets/fonts/Inter_500Medium.ttf",
|
||||
"./assets/fonts/Inter_500Medium_Italic.ttf",
|
||||
"./assets/fonts/Inter_600SemiBold.ttf",
|
||||
"./assets/fonts/Inter_600SemiBold_Italic.ttf",
|
||||
"./assets/fonts/Inter_700Bold.ttf",
|
||||
"./assets/fonts/Inter_700Bold_Italic.ttf",
|
||||
"./assets/fonts/Inter_800ExtraBold.ttf",
|
||||
"./assets/fonts/Inter_800ExtraBold_Italic.ttf",
|
||||
"./assets/fonts/Inter_900Black.ttf",
|
||||
"./assets/fonts/Inter_900Black_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_200ExtraLight.ttf",
|
||||
"./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_300Light.ttf",
|
||||
"./assets/fonts/SourceSerif4_300Light_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_400Regular.ttf",
|
||||
"./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_500Medium.ttf",
|
||||
"./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_600SemiBold.ttf",
|
||||
"./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_700Bold.ttf",
|
||||
"./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_800ExtraBold.ttf",
|
||||
"./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_900Black.ttf",
|
||||
"./assets/fonts/SourceSerif4_900Black_Italic.ttf"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
|
||||
BIN
apps/aris-client/assets/fonts/Inter_100Thin.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_100Thin.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_100Thin_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_100Thin_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_300Light.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_300Light.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_300Light_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_300Light_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_400Regular.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_400Regular.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_400Regular_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_400Regular_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_500Medium.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_500Medium.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_500Medium_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_500Medium_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_700Bold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_700Bold.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_700Bold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_700Bold_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_900Black.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_900Black.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/Inter_900Black_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_900Black_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_200ExtraLight.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_200ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_600SemiBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_600SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold_Italic.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_800ExtraBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_800ExtraBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black.ttf
Normal file
Binary file not shown.
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black_Italic.ttf
Normal file
Binary file not shown.
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
|
||||
9
bun.lock
9
bun.lock
@@ -21,8 +21,6 @@
|
||||
"@aris/source-location": "workspace:*",
|
||||
"@aris/source-tfl": "workspace:*",
|
||||
"@aris/source-weatherkit": "workspace:*",
|
||||
"@hono/trpc-server": "^0.3",
|
||||
"@trpc/server": "^11",
|
||||
"arktype": "^2.1.29",
|
||||
"better-auth": "^1",
|
||||
"hono": "^4",
|
||||
@@ -37,6 +35,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
@@ -385,6 +384,8 @@
|
||||
|
||||
"@expo-google-fonts/inter": ["@expo-google-fonts/inter@0.4.2", "", {}, "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ=="],
|
||||
|
||||
"@expo-google-fonts/source-serif-4": ["@expo-google-fonts/source-serif-4@0.4.1", "", {}, "sha512-Ej4UXDjW1kwYPHG8YLq6fK1bqnJGb3K35J3S5atSL0ScKFAFLKvndxoTWeCls7mybtlS9x99hzwDeXCBkiI3rA=="],
|
||||
|
||||
"@expo/apple-utils": ["@expo/apple-utils@2.1.13", "", { "bin": { "apple-utils": "bin.js" } }, "sha512-nt3efiJhAWTHl9ikKYrHEuv3dhqCdicsHFRE9LmvtcVsPhXl9bAsm0gbACoLPr7ClP8664H/S6SdVJOD/tw0jg=="],
|
||||
|
||||
"@expo/bunyan": ["@expo/bunyan@4.0.1", "", { "dependencies": { "uuid": "^8.0.0" } }, "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg=="],
|
||||
@@ -465,8 +466,6 @@
|
||||
|
||||
"@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="],
|
||||
|
||||
"@hono/trpc-server": ["@hono/trpc-server@0.3.4", "", { "peerDependencies": { "@trpc/server": "^10.10.0 || >11.0.0-rc", "hono": ">=4.*" } }, "sha512-xFOPjUPnII70FgicDzOJy1ufIoBTu8eF578zGiDOrYOrYN8CJe140s9buzuPkX+SwJRYK8LjEBHywqZtxdm8aA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
@@ -683,8 +682,6 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@trpc/server": ["@trpc/server@11.10.0", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-zZjTrR6He61e5TiT7e/bQqab/jRcXBZM8Fg78Yoo8uh5pz60dzzbYuONNUCOkafv5ppXVMms4NHYfNZgzw50vg=="],
|
||||
|
||||
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
|
||||
|
||||
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
|
||||
|
||||
@@ -638,4 +638,290 @@ describe("FeedEngine", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("lastFeed", () => {
|
||||
test("returns null before any refresh", () => {
|
||||
const engine = new FeedEngine()
|
||||
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
|
||||
test("returns cached result after refresh", async () => {
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
const weather = createWeatherSource()
|
||||
const engine = new FeedEngine().register(location).register(weather)
|
||||
|
||||
const refreshResult = await engine.refresh()
|
||||
|
||||
const cached = engine.lastFeed()
|
||||
expect(cached).not.toBeNull()
|
||||
expect(cached!.items).toEqual(refreshResult.items)
|
||||
expect(cached!.context).toEqual(refreshResult.context)
|
||||
})
|
||||
|
||||
test("returns null after TTL expires", async () => {
|
||||
const engine = new FeedEngine({ cacheTtlMs: 50 })
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
engine.register(location)
|
||||
await engine.refresh()
|
||||
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
|
||||
test("defaults to 5 minute TTL", async () => {
|
||||
const engine = new FeedEngine()
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
engine.register(location)
|
||||
await engine.refresh()
|
||||
|
||||
// Should still be cached immediately
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
})
|
||||
|
||||
test("refresh always fetches from sources", async () => {
|
||||
let fetchCount = 0
|
||||
const source: FeedSource = {
|
||||
id: "counter",
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
fetchCount++
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine().register(source)
|
||||
|
||||
await engine.refresh()
|
||||
await engine.refresh()
|
||||
await engine.refresh()
|
||||
|
||||
expect(fetchCount).toBe(3)
|
||||
})
|
||||
|
||||
test("reactive context update refreshes cache", async () => {
|
||||
const location = createLocationSource()
|
||||
const weather = createWeatherSource()
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 5000 }).register(location).register(weather)
|
||||
|
||||
engine.start()
|
||||
|
||||
// Simulate location update which triggers reactive refresh
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
// Wait for async reactive refresh to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const cached = engine.lastFeed()
|
||||
expect(cached).not.toBeNull()
|
||||
expect(cached!.items.length).toBeGreaterThan(0)
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("reactive item update refreshes cache", async () => {
|
||||
let itemUpdateCallback: (() => void) | null = null
|
||||
|
||||
const source: FeedSource = {
|
||||
id: "reactive-items",
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return [
|
||||
{
|
||||
id: "item-1",
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: {},
|
||||
},
|
||||
]
|
||||
},
|
||||
onItemsUpdate(callback) {
|
||||
itemUpdateCallback = callback
|
||||
return () => {
|
||||
itemUpdateCallback = null
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine().register(source)
|
||||
engine.start()
|
||||
|
||||
// Trigger item update
|
||||
itemUpdateCallback!()
|
||||
|
||||
// Wait for async refresh
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const cached = engine.lastFeed()
|
||||
expect(cached).not.toBeNull()
|
||||
expect(cached!.items).toHaveLength(1)
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("TTL resets after reactive update", async () => {
|
||||
const location = createLocationSource()
|
||||
const weather = createWeatherSource()
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
|
||||
|
||||
engine.start()
|
||||
|
||||
// Initial reactive update
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
// Wait 70ms (total 120ms from first update, past original TTL)
|
||||
// but trigger another update at 50ms to reset TTL
|
||||
location.simulateUpdate({ lat: 52.0, lng: -0.2 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Should still be cached because TTL was reset by second update
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("cacheTtlMs is configurable", async () => {
|
||||
const engine = new FeedEngine({ cacheTtlMs: 30 })
|
||||
const location = createLocationSource()
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
|
||||
engine.register(location)
|
||||
await engine.refresh()
|
||||
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
|
||||
test("auto-refreshes on TTL interval after start", async () => {
|
||||
let fetchCount = 0
|
||||
const source: FeedSource = {
|
||||
id: "counter",
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
fetchCount++
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return [
|
||||
{
|
||||
id: `item-${fetchCount}`,
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: {},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
|
||||
engine.start()
|
||||
|
||||
// Wait for two TTL intervals to elapse
|
||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||
|
||||
// Should have auto-refreshed at least twice
|
||||
expect(fetchCount).toBeGreaterThanOrEqual(2)
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("stop cancels periodic refresh", async () => {
|
||||
let fetchCount = 0
|
||||
const source: FeedSource = {
|
||||
id: "counter",
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
fetchCount++
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
|
||||
engine.start()
|
||||
engine.stop()
|
||||
|
||||
const countAfterStop = fetchCount
|
||||
|
||||
// Wait past TTL
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
// No additional fetches after stop
|
||||
expect(fetchCount).toBe(countAfterStop)
|
||||
})
|
||||
|
||||
test("reactive update resets periodic refresh timer", async () => {
|
||||
let fetchCount = 0
|
||||
const location = createLocationSource()
|
||||
const countingWeather: FeedSource<WeatherFeedItem> = {
|
||||
id: "weather",
|
||||
dependencies: ["location"],
|
||||
...noActions,
|
||||
async fetchContext(ctx) {
|
||||
fetchCount++
|
||||
const loc = contextValue(ctx, LocationKey)
|
||||
if (!loc) return null
|
||||
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||
},
|
||||
async fetchItems(ctx) {
|
||||
const weather = contextValue(ctx, WeatherKey)
|
||||
if (!weather) return []
|
||||
return [
|
||||
{
|
||||
id: `weather-${Date.now()}`,
|
||||
type: "weather",
|
||||
priority: 0.5,
|
||||
timestamp: new Date(),
|
||||
data: { temperature: weather.temperature, condition: weather.condition },
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
const engine = new FeedEngine({ cacheTtlMs: 100 })
|
||||
.register(location)
|
||||
.register(countingWeather)
|
||||
|
||||
engine.start()
|
||||
|
||||
// At 40ms, push a reactive update — this resets the timer
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
const countBeforeUpdate = fetchCount
|
||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
// Reactive update triggered a fetch
|
||||
expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
|
||||
const countAfterUpdate = fetchCount
|
||||
|
||||
// At 100ms from start (60ms after reactive update), the original
|
||||
// timer would have fired, but it was reset. No extra fetch yet.
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(fetchCount).toBe(countAfterUpdate)
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,14 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
|
||||
|
||||
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 300_000 // 5 minutes
|
||||
const MIN_CACHE_TTL_MS = 10 // prevent spin from zero/negative values
|
||||
|
||||
export interface FeedEngineConfig {
|
||||
/** Cache TTL in milliseconds. Default: 300_000 (5 minutes). Minimum: 10. */
|
||||
cacheTtlMs?: number
|
||||
}
|
||||
|
||||
interface SourceGraph {
|
||||
sources: Map<string, FeedSource>
|
||||
sorted: FeedSource[]
|
||||
@@ -59,6 +67,29 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
private cleanups: Array<() => void> = []
|
||||
private started = false
|
||||
|
||||
private readonly cacheTtlMs: number
|
||||
private cachedResult: FeedResult<TItems> | null = null
|
||||
private cachedAt: number | null = null
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(config?: FeedEngineConfig) {
|
||||
this.cacheTtlMs = Math.max(config?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS, MIN_CACHE_TTL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached FeedResult if available and not expired.
|
||||
* Returns null if no refresh has completed or the cache TTL has elapsed.
|
||||
*/
|
||||
lastFeed(): FeedResult<TItems> | null {
|
||||
if (this.cachedResult === null || this.cachedAt === null) {
|
||||
return null
|
||||
}
|
||||
if (Date.now() - this.cachedAt > this.cacheTtlMs) {
|
||||
return null
|
||||
}
|
||||
return this.cachedResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a FeedSource. Invalidates the cached graph.
|
||||
*/
|
||||
@@ -124,7 +155,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
|
||||
this.context = context
|
||||
|
||||
return { context, items: items as TItems[], errors }
|
||||
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
|
||||
this.updateCache(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +172,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reactive subscriptions on all sources.
|
||||
* Starts reactive subscriptions on all sources and begins periodic refresh.
|
||||
* Sources with onContextUpdate will trigger re-computation of dependents.
|
||||
*/
|
||||
start(): void {
|
||||
@@ -168,13 +202,16 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
this.cleanups.push(cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduleNextRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops all reactive subscriptions.
|
||||
* Stops all reactive subscriptions and the periodic refresh timer.
|
||||
*/
|
||||
stop(): void {
|
||||
this.started = false
|
||||
this.cancelScheduledRefresh()
|
||||
for (const cleanup of this.cleanups) {
|
||||
cleanup()
|
||||
}
|
||||
@@ -279,11 +316,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
|
||||
items.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
this.notifySubscribers({
|
||||
const result: FeedResult<TItems> = {
|
||||
context: this.context,
|
||||
items: items as TItems[],
|
||||
errors,
|
||||
})
|
||||
}
|
||||
this.updateCache(result)
|
||||
|
||||
this.notifySubscribers(result)
|
||||
}
|
||||
|
||||
private collectDependents(sourceId: string, graph: SourceGraph): string[] {
|
||||
@@ -307,11 +347,46 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id)
|
||||
}
|
||||
|
||||
private updateCache(result: FeedResult<TItems>): void {
|
||||
this.cachedResult = result
|
||||
this.cachedAt = Date.now()
|
||||
if (this.started) {
|
||||
this.scheduleNextRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNextRefresh(): void {
|
||||
this.cancelScheduledRefresh()
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refresh()
|
||||
.then((result) => {
|
||||
this.notifySubscribers(result)
|
||||
})
|
||||
.catch(() => {
|
||||
// Periodic refresh errors are non-fatal; schedule next attempt
|
||||
if (this.started) {
|
||||
this.scheduleNextRefresh()
|
||||
}
|
||||
})
|
||||
}, this.cacheTtlMs)
|
||||
}
|
||||
|
||||
private cancelScheduledRefresh(): void {
|
||||
if (this.refreshTimer !== null) {
|
||||
clearTimeout(this.refreshTimer)
|
||||
this.refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
// Simple immediate refresh for now - could add debouncing later
|
||||
this.refresh().then((result) => {
|
||||
this.notifySubscribers(result)
|
||||
})
|
||||
this.refresh()
|
||||
.then((result) => {
|
||||
this.notifySubscribers(result)
|
||||
})
|
||||
.catch(() => {
|
||||
// Reactive refresh errors are non-fatal
|
||||
})
|
||||
}
|
||||
|
||||
private notifySubscribers(result: FeedResult<TItems>): void {
|
||||
|
||||
@@ -13,7 +13,7 @@ export type { FeedItem } from "./feed"
|
||||
export type { FeedSource } from "./feed-source"
|
||||
|
||||
// Feed Engine
|
||||
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||
export { FeedEngine } from "./feed-engine"
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user