mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
27 Commits
feat/aris-
...
docs/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
cdf5a52c2e
|
|||
| ee957ea7b1 | |||
| 6ae0ad1d40 | |||
|
941acb826c
|
|||
| 3d492a5d56 | |||
|
08dd437952
|
|||
| 2fc20759dd | |||
|
963bf073d1
|
|||
| c0b3db0e11 | |||
|
ca4a337dcd
|
|||
| 769e2d4eb0 | |||
|
5e9094710d
|
|||
|
5556f3fbf9
|
|||
|
0176979925
|
|||
|
971aba0932
|
|||
|
68e319e4b8
|
|||
| c042af88f3 | |||
|
0608f2ac61
|
|||
| 1ade63dd8c | |||
|
8df340d9af
|
|||
|
727280e8b1
|
|||
| d30f70494b | |||
| 413a57c156 | |||
|
d9625198d6
|
|||
| 959167a93c | |||
| cd29a60bab | |||
|
2f082b5833
|
@@ -13,8 +13,6 @@
|
|||||||
"@aris/source-location": "workspace:*",
|
"@aris/source-location": "workspace:*",
|
||||||
"@aris/source-tfl": "workspace:*",
|
"@aris/source-tfl": "workspace:*",
|
||||||
"@aris/source-weatherkit": "workspace:*",
|
"@aris/source-weatherkit": "workspace:*",
|
||||||
"@hono/trpc-server": "^0.3",
|
|
||||||
"@trpc/server": "^11",
|
|
||||||
"arktype": "^2.1.29",
|
"arktype": "^2.1.29",
|
||||||
"better-auth": "^1",
|
"better-auth": "^1",
|
||||||
"hono": "^4",
|
"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"
|
import { auth } from "./index.ts"
|
||||||
|
|
||||||
type SessionUser = typeof auth.$Infer.Session.user
|
|
||||||
type Session = typeof auth.$Infer.Session.session
|
|
||||||
|
|
||||||
export interface SessionVariables {
|
export interface SessionVariables {
|
||||||
user: SessionUser | null
|
user: AuthUser | null
|
||||||
session: Session | 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(
|
export async function getSessionFromHeaders(
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
): Promise<{ user: SessionUser; session: Session } | null> {
|
): Promise<{ user: AuthUser; session: AuthSession } | null> {
|
||||||
const session = await auth.api.getSession({ headers })
|
const session = await auth.api.getSession({ headers })
|
||||||
return session
|
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,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,37 +0,0 @@
|
|||||||
import { TRPCError } from "@trpc/server"
|
|
||||||
import { type } from "arktype"
|
|
||||||
|
|
||||||
import type { TRPC } from "../trpc/router.ts"
|
|
||||||
import type { LocationService } from "./service.ts"
|
|
||||||
|
|
||||||
import { UserNotFoundError } from "../lib/error.ts"
|
|
||||||
|
|
||||||
const locationInput = type({
|
|
||||||
lat: "number",
|
|
||||||
lng: "number",
|
|
||||||
accuracy: "number",
|
|
||||||
timestamp: "Date",
|
|
||||||
})
|
|
||||||
|
|
||||||
export function createLocationRouter(
|
|
||||||
t: TRPC,
|
|
||||||
{ locationService }: { locationService: LocationService },
|
|
||||||
) {
|
|
||||||
return t.router({
|
|
||||||
update: t.procedure.input(locationInput).mutation(({ input, ctx }) => {
|
|
||||||
try {
|
|
||||||
locationService.updateUserLocation(ctx.user.id, {
|
|
||||||
lat: input.lat,
|
|
||||||
lng: input.lng,
|
|
||||||
accuracy: input.accuracy,
|
|
||||||
timestamp: input.timestamp,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UserNotFoundError) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message })
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import { UserNotFoundError } from "../lib/error.ts"
|
|
||||||
import { LocationService } from "./service.ts"
|
|
||||||
|
|
||||||
describe("LocationService", () => {
|
|
||||||
test("feedSourceForUser creates source on first call", () => {
|
|
||||||
const service = new LocationService()
|
|
||||||
const source = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(source).toBeDefined()
|
|
||||||
expect(source.id).toBe("aris.location")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feedSourceForUser returns same source for same user", () => {
|
|
||||||
const service = new LocationService()
|
|
||||||
const source1 = service.feedSourceForUser("user-1")
|
|
||||||
const source2 = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(source1).toBe(source2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feedSourceForUser returns different sources for different users", () => {
|
|
||||||
const service = new LocationService()
|
|
||||||
const source1 = service.feedSourceForUser("user-1")
|
|
||||||
const source2 = service.feedSourceForUser("user-2")
|
|
||||||
|
|
||||||
expect(source1).not.toBe(source2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("updateUserLocation updates the source", () => {
|
|
||||||
const service = new LocationService()
|
|
||||||
const source = service.feedSourceForUser("user-1")
|
|
||||||
const location = {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
service.updateUserLocation("user-1", location)
|
|
||||||
|
|
||||||
expect(source.lastLocation).toEqual(location)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("updateUserLocation throws if source does not exist", () => {
|
|
||||||
const service = new LocationService()
|
|
||||||
const location = {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(() => service.updateUserLocation("user-1", location)).toThrow(UserNotFoundError)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("lastUserLocation returns null for unknown user", () => {
|
|
||||||
const service = new LocationService()
|
|
||||||
|
|
||||||
expect(service.lastUserLocation("unknown")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("lastUserLocation returns last location", () => {
|
|
||||||
const service = new LocationService()
|
|
||||||
service.feedSourceForUser("user-1")
|
|
||||||
const location1 = {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
}
|
|
||||||
const location2 = {
|
|
||||||
lat: 52.0,
|
|
||||||
lng: -0.2,
|
|
||||||
accuracy: 5,
|
|
||||||
timestamp: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
service.updateUserLocation("user-1", location1)
|
|
||||||
service.updateUserLocation("user-1", location2)
|
|
||||||
|
|
||||||
expect(service.lastUserLocation("user-1")).toEqual(location2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removeUser removes the source", () => {
|
|
||||||
const service = new LocationService()
|
|
||||||
service.feedSourceForUser("user-1")
|
|
||||||
const location = {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
service.updateUserLocation("user-1", location)
|
|
||||||
service.removeUser("user-1")
|
|
||||||
|
|
||||||
expect(service.lastUserLocation("user-1")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removeUser allows new source to be created", () => {
|
|
||||||
const service = new LocationService()
|
|
||||||
const source1 = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
service.removeUser("user-1")
|
|
||||||
const source2 = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(source1).not.toBe(source2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { LocationSource, type Location } from "@aris/source-location"
|
|
||||||
|
|
||||||
import type { FeedSourceProvider } from "../feed/service.ts"
|
|
||||||
|
|
||||||
import { UserNotFoundError } from "../lib/error.ts"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages LocationSource instances per user.
|
|
||||||
*/
|
|
||||||
export class LocationService implements FeedSourceProvider {
|
|
||||||
private sources = new Map<string, LocationSource>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create a LocationSource for a user.
|
|
||||||
* @param userId - The user's unique identifier
|
|
||||||
* @returns The user's LocationSource instance
|
|
||||||
*/
|
|
||||||
feedSourceForUser(userId: string): LocationSource {
|
|
||||||
let source = this.sources.get(userId)
|
|
||||||
if (!source) {
|
|
||||||
source = new LocationSource()
|
|
||||||
this.sources.set(userId, source)
|
|
||||||
}
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update location for a user.
|
|
||||||
* @param userId - The user's unique identifier
|
|
||||||
* @param location - The new location data
|
|
||||||
* @throws {UserNotFoundError} If no source exists for the user
|
|
||||||
*/
|
|
||||||
updateUserLocation(userId: string, location: Location): void {
|
|
||||||
const source = this.sources.get(userId)
|
|
||||||
if (!source) {
|
|
||||||
throw new UserNotFoundError(userId)
|
|
||||||
}
|
|
||||||
source.pushLocation(location)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get last known location for a user.
|
|
||||||
* @param userId - The user's unique identifier
|
|
||||||
* @returns The last location, or null if none exists
|
|
||||||
*/
|
|
||||||
lastUserLocation(userId: string): Location | null {
|
|
||||||
return this.sources.get(userId)?.lastLocation ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a user's LocationSource.
|
|
||||||
* @param userId - The user's unique identifier
|
|
||||||
*/
|
|
||||||
removeUser(userId: string): void {
|
|
||||||
this.sources.delete(userId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,33 @@
|
|||||||
import { trpcServer } from "@hono/trpc-server"
|
import { LocationSource } from "@aris/source-location"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { registerAuthHandlers } from "./auth/http.ts"
|
import { registerAuthHandlers } from "./auth/http.ts"
|
||||||
import { LocationService } from "./location/service.ts"
|
import { requireSession } from "./auth/session-middleware.ts"
|
||||||
import { createContext } from "./trpc/context.ts"
|
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
||||||
import { createTRPCRouter } from "./trpc/router.ts"
|
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||||
import { WeatherService } from "./weather/service.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const locationService = new LocationService()
|
const sessionManager = new UserSessionManager([
|
||||||
|
() => new LocationSource(),
|
||||||
const weatherService = new WeatherService({
|
new WeatherSourceProvider({
|
||||||
credentials: {
|
credentials: {
|
||||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||||
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
])
|
||||||
const trpcRouter = createTRPCRouter({ locationService, weatherService })
|
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
registerAuthHandlers(app)
|
registerAuthHandlers(app)
|
||||||
|
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
|
||||||
app.use(
|
registerLocationHttpHandlers(app, { sessionManager })
|
||||||
"/trpc/*",
|
|
||||||
trpcServer({
|
|
||||||
router: trpcRouter,
|
|
||||||
createContext,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
9
apps/aris-backend/src/session/feed-source-provider.ts
Normal file
9
apps/aris-backend/src/session/feed-source-provider.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { FeedSource } from "@aris/core"
|
||||||
|
|
||||||
|
export interface FeedSourceProvider {
|
||||||
|
feedSourceForUser(userId: string): FeedSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedSourceProviderFn = (userId: string) => FeedSource
|
||||||
|
|
||||||
|
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
||||||
7
apps/aris-backend/src/session/index.ts
Normal file
7
apps/aris-backend/src/session/index.ts
Normal 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"
|
||||||
166
apps/aris-backend/src/session/user-session-manager.test.ts
Normal file
166
apps/aris-backend/src/session/user-session-manager.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
const mockWeatherClient: WeatherKitClient = {
|
||||||
|
fetch: async () => ({}) as WeatherKitResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UserSessionManager", () => {
|
||||||
|
test("getOrCreate creates session on first call", () => {
|
||||||
|
const manager = new UserSessionManager([() => 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([() => 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([() => 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([() => new LocationSource()])
|
||||||
|
|
||||||
|
const session1 = manager.getOrCreate("user-1")
|
||||||
|
const session2 = manager.getOrCreate("user-2")
|
||||||
|
|
||||||
|
const source1 = session1.getSource<LocationSource>("aris.location")
|
||||||
|
const source2 = session2.getSource<LocationSource>("aris.location")
|
||||||
|
|
||||||
|
expect(source1).not.toBe(source2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("remove destroys session and allows re-creation", () => {
|
||||||
|
const manager = new UserSessionManager([() => 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([() => new LocationSource()])
|
||||||
|
|
||||||
|
expect(() => manager.remove("unknown")).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts function providers", async () => {
|
||||||
|
const manager = new UserSessionManager([() => 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([() => new LocationSource(), provider])
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(session.getSource("aris.weather")).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts mixed providers", () => {
|
||||||
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
|
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(session.getSource("aris.location")).toBeDefined()
|
||||||
|
expect(session.getSource("aris.weather")).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refresh returns feed result through session", async () => {
|
||||||
|
const manager = new UserSessionManager([() => 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([() => new LocationSource()])
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
await session.engine.executeAction("aris.location", "update-location", {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const source = session.getSource<LocationSource>("aris.location")
|
||||||
|
expect(source?.lastLocation?.lat).toBe(51.5074)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("subscribe receives updates after location push", async () => {
|
||||||
|
const manager = new UserSessionManager([() => new LocationSource()])
|
||||||
|
const callback = mock()
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
session.engine.subscribe(callback)
|
||||||
|
|
||||||
|
await session.engine.executeAction("aris.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([() => 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("aris.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()
|
||||||
|
})
|
||||||
|
})
|
||||||
32
apps/aris-backend/src/session/user-session-manager.ts
Normal file
32
apps/aris-backend/src/session/user-session-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||||
|
|
||||||
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
|
export class UserSessionManager {
|
||||||
|
private sessions = new Map<string, UserSession>()
|
||||||
|
private readonly providers: FeedSourceProviderInput[]
|
||||||
|
|
||||||
|
constructor(providers: FeedSourceProviderInput[]) {
|
||||||
|
this.providers = providers
|
||||||
|
}
|
||||||
|
|
||||||
|
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.sessions.set(userId, session)
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(userId: string): void {
|
||||||
|
const session = this.sessions.get(userId)
|
||||||
|
if (session) {
|
||||||
|
session.destroy()
|
||||||
|
this.sessions.delete(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/aris-backend/src/session/user-session.test.ts
Normal file
72
apps/aris-backend/src/session/user-session.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
|
import { LocationSource } from "@aris/source-location"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
|
function createStubSource(id: string): 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 []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>("aris.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("aris.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)
|
||||||
|
})
|
||||||
|
})
|
||||||
24
apps/aris-backend/src/session/user-session.ts
Normal file
24
apps/aris-backend/src/session/user-session.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { FeedEngine, type FeedSource } from "@aris/core"
|
||||||
|
|
||||||
|
export class UserSession {
|
||||||
|
readonly engine: FeedEngine
|
||||||
|
private sources = new Map<string, FeedSource>()
|
||||||
|
|
||||||
|
constructor(sources: FeedSource[]) {
|
||||||
|
this.engine = new FeedEngine()
|
||||||
|
for (const source of sources) {
|
||||||
|
this.sources.set(source.id, source)
|
||||||
|
this.engine.register(source)
|
||||||
|
}
|
||||||
|
this.engine.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
||||||
|
return this.sources.get(sourceId) as T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.engine.stop()
|
||||||
|
this.sources.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/aris-backend/src/tfl/provider.ts
Normal file
19
apps/aris-backend/src/tfl/provider.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { TflSource, type ITflApi } from "@aris/source-tfl"
|
||||||
|
|
||||||
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
|
export type TflSourceProviderOptions =
|
||||||
|
| { apiKey: string; client?: never }
|
||||||
|
| { apiKey?: never; client: ITflApi }
|
||||||
|
|
||||||
|
export class TflSourceProvider implements FeedSourceProvider {
|
||||||
|
private readonly options: TflSourceProviderOptions
|
||||||
|
|
||||||
|
constructor(options: TflSourceProviderOptions) {
|
||||||
|
this.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
feedSourceForUser(_userId: string): TflSource {
|
||||||
|
return new TflSource(this.options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import type { Context } from "@aris/core"
|
|
||||||
import type { ITflApi, StationLocation, TflLineId, TflLineStatus } from "@aris/source-tfl"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import { UserNotFoundError } from "../lib/error.ts"
|
|
||||||
import { TflService } from "./service.ts"
|
|
||||||
|
|
||||||
class StubTflApi implements ITflApi {
|
|
||||||
private statuses: TflLineStatus[]
|
|
||||||
|
|
||||||
constructor(statuses: TflLineStatus[] = []) {
|
|
||||||
this.statuses = statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
|
||||||
if (lines) {
|
|
||||||
return this.statuses.filter((s) => lines.includes(s.lineId))
|
|
||||||
}
|
|
||||||
return this.statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchStations(): Promise<StationLocation[]> {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "940GZZLUKSX",
|
|
||||||
name: "King's Cross",
|
|
||||||
lat: 51.5308,
|
|
||||||
lng: -0.1238,
|
|
||||||
lines: ["northern", "victoria"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createContext(): Context {
|
|
||||||
return { time: new Date("2026-01-15T12:00:00Z") }
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleStatuses: TflLineStatus[] = [
|
|
||||||
{
|
|
||||||
lineId: "northern",
|
|
||||||
lineName: "Northern",
|
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Minor delays on the Northern line",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lineId: "victoria",
|
|
||||||
lineName: "Victoria",
|
|
||||||
severity: "major-delays",
|
|
||||||
description: "Severe delays on the Victoria line",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lineId: "central",
|
|
||||||
lineName: "Central",
|
|
||||||
severity: "closure",
|
|
||||||
description: "Central line suspended",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
describe("TflService", () => {
|
|
||||||
test("feedSourceForUser creates source on first call", () => {
|
|
||||||
const service = new TflService(new StubTflApi())
|
|
||||||
const source = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(source).toBeDefined()
|
|
||||||
expect(source.id).toBe("aris.tfl")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feedSourceForUser returns same source for same user", () => {
|
|
||||||
const service = new TflService(new StubTflApi())
|
|
||||||
const source1 = service.feedSourceForUser("user-1")
|
|
||||||
const source2 = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(source1).toBe(source2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feedSourceForUser returns different sources for different users", () => {
|
|
||||||
const service = new TflService(new StubTflApi())
|
|
||||||
const source1 = service.feedSourceForUser("user-1")
|
|
||||||
const source2 = service.feedSourceForUser("user-2")
|
|
||||||
|
|
||||||
expect(source1).not.toBe(source2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("updateLinesOfInterest mutates the existing source in place", () => {
|
|
||||||
const service = new TflService(new StubTflApi())
|
|
||||||
const original = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
service.updateLinesOfInterest("user-1", ["northern", "victoria"])
|
|
||||||
const after = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(after).toBe(original)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("updateLinesOfInterest throws if source does not exist", () => {
|
|
||||||
const service = new TflService(new StubTflApi())
|
|
||||||
|
|
||||||
expect(() => service.updateLinesOfInterest("user-1", ["northern"])).toThrow(UserNotFoundError)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removeUser removes the source", () => {
|
|
||||||
const service = new TflService(new StubTflApi())
|
|
||||||
const source1 = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
service.removeUser("user-1")
|
|
||||||
const source2 = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(source1).not.toBe(source2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removeUser clears line configuration", async () => {
|
|
||||||
const api = new StubTflApi(sampleStatuses)
|
|
||||||
const service = new TflService(api)
|
|
||||||
service.feedSourceForUser("user-1")
|
|
||||||
service.updateLinesOfInterest("user-1", ["northern"])
|
|
||||||
|
|
||||||
service.removeUser("user-1")
|
|
||||||
const items = await service.feedSourceForUser("user-1").fetchItems(createContext())
|
|
||||||
|
|
||||||
expect(items.length).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shares single api instance across users", () => {
|
|
||||||
const api = new StubTflApi()
|
|
||||||
const service = new TflService(api)
|
|
||||||
|
|
||||||
service.feedSourceForUser("user-1")
|
|
||||||
service.feedSourceForUser("user-2")
|
|
||||||
|
|
||||||
expect(service.feedSourceForUser("user-1").id).toBe("aris.tfl")
|
|
||||||
expect(service.feedSourceForUser("user-2").id).toBe("aris.tfl")
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("returned source fetches items", () => {
|
|
||||||
test("source returns feed items from api", async () => {
|
|
||||||
const api = new StubTflApi(sampleStatuses)
|
|
||||||
const service = new TflService(api)
|
|
||||||
const source = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext())
|
|
||||||
|
|
||||||
expect(items.length).toBe(3)
|
|
||||||
for (const item of items) {
|
|
||||||
expect(item.type).toBe("tfl-alert")
|
|
||||||
expect(item.id).toMatch(/^tfl-alert-/)
|
|
||||||
expect(typeof item.priority).toBe("number")
|
|
||||||
expect(item.timestamp).toBeInstanceOf(Date)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("source returns items sorted by priority descending", async () => {
|
|
||||||
const api = new StubTflApi(sampleStatuses)
|
|
||||||
const service = new TflService(api)
|
|
||||||
const source = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext())
|
|
||||||
|
|
||||||
for (let i = 1; i < items.length; i++) {
|
|
||||||
expect(items[i - 1]!.priority).toBeGreaterThanOrEqual(items[i]!.priority)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("source returns empty array when no disruptions", async () => {
|
|
||||||
const api = new StubTflApi([])
|
|
||||||
const service = new TflService(api)
|
|
||||||
const source = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
const items = await source.fetchItems(createContext())
|
|
||||||
|
|
||||||
expect(items).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("updateLinesOfInterest filters items to configured lines", async () => {
|
|
||||||
const api = new StubTflApi(sampleStatuses)
|
|
||||||
const service = new TflService(api)
|
|
||||||
|
|
||||||
const before = await service.feedSourceForUser("user-1").fetchItems(createContext())
|
|
||||||
expect(before.length).toBe(3)
|
|
||||||
|
|
||||||
service.updateLinesOfInterest("user-1", ["northern"])
|
|
||||||
const after = await service.feedSourceForUser("user-1").fetchItems(createContext())
|
|
||||||
|
|
||||||
expect(after.length).toBe(1)
|
|
||||||
expect(after[0]!.data.line).toBe("northern")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("different users get independent line configs", async () => {
|
|
||||||
const api = new StubTflApi(sampleStatuses)
|
|
||||||
const service = new TflService(api)
|
|
||||||
service.feedSourceForUser("user-1")
|
|
||||||
service.feedSourceForUser("user-2")
|
|
||||||
|
|
||||||
service.updateLinesOfInterest("user-1", ["northern"])
|
|
||||||
service.updateLinesOfInterest("user-2", ["central"])
|
|
||||||
|
|
||||||
const items1 = await service.feedSourceForUser("user-1").fetchItems(createContext())
|
|
||||||
const items2 = await service.feedSourceForUser("user-2").fetchItems(createContext())
|
|
||||||
|
|
||||||
expect(items1.length).toBe(1)
|
|
||||||
expect(items1[0]!.data.line).toBe("northern")
|
|
||||||
expect(items2.length).toBe(1)
|
|
||||||
expect(items2[0]!.data.line).toBe("central")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { TflSource, type ITflApi, type TflLineId } from "@aris/source-tfl"
|
|
||||||
|
|
||||||
import type { FeedSourceProvider } from "../feed/service.ts"
|
|
||||||
|
|
||||||
import { UserNotFoundError } from "../lib/error.ts"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages per-user TflSource instances with individual line configuration.
|
|
||||||
*/
|
|
||||||
export class TflService implements FeedSourceProvider {
|
|
||||||
private sources = new Map<string, TflSource>()
|
|
||||||
|
|
||||||
constructor(private readonly api: ITflApi) {}
|
|
||||||
|
|
||||||
feedSourceForUser(userId: string): TflSource {
|
|
||||||
let source = this.sources.get(userId)
|
|
||||||
if (!source) {
|
|
||||||
source = new TflSource({ client: this.api })
|
|
||||||
this.sources.set(userId, source)
|
|
||||||
}
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update monitored lines for a user. Mutates the existing TflSource
|
|
||||||
* so that references held by FeedEngine remain valid.
|
|
||||||
* @throws {UserNotFoundError} If no source exists for the user
|
|
||||||
*/
|
|
||||||
updateLinesOfInterest(userId: string, lines: TflLineId[]): void {
|
|
||||||
const source = this.sources.get(userId)
|
|
||||||
if (!source) {
|
|
||||||
throw new UserNotFoundError(userId)
|
|
||||||
}
|
|
||||||
source.setLinesOfInterest(lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
removeUser(userId: string): void {
|
|
||||||
this.sources.delete(userId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,50 +0,0 @@
|
|||||||
import { initTRPC, TRPCError } from "@trpc/server"
|
|
||||||
|
|
||||||
import type { LocationService } from "../location/service.ts"
|
|
||||||
import type { WeatherService } from "../weather/service.ts"
|
|
||||||
import type { Context } from "./context.ts"
|
|
||||||
|
|
||||||
import { createLocationRouter } from "../location/router.ts"
|
|
||||||
|
|
||||||
interface AuthedContext {
|
|
||||||
user: NonNullable<Context["user"]>
|
|
||||||
session: NonNullable<Context["session"]>
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TRPC = ReturnType<typeof createTRPC>
|
|
||||||
|
|
||||||
export interface TRPCRouterDeps {
|
|
||||||
locationService: LocationService
|
|
||||||
weatherService: WeatherService
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTRPCRouter({ locationService }: TRPCRouterDeps) {
|
|
||||||
const t = createTRPC()
|
|
||||||
|
|
||||||
return t.router({
|
|
||||||
location: createLocationRouter(t, { locationService }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TRPCRouter = ReturnType<typeof createTRPCRouter>
|
|
||||||
15
apps/aris-backend/src/weather/provider.ts
Normal file
15
apps/aris-backend/src/weather/provider.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit"
|
||||||
|
|
||||||
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
|
export class WeatherSourceProvider implements FeedSourceProvider {
|
||||||
|
private readonly options: WeatherSourceOptions
|
||||||
|
|
||||||
|
constructor(options: WeatherSourceOptions) {
|
||||||
|
this.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
feedSourceForUser(_userId: string): WeatherSource {
|
||||||
|
return new WeatherSource(this.options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import type { Context } from "@aris/core"
|
|
||||||
|
|
||||||
import { LocationKey } from "@aris/source-location"
|
|
||||||
import {
|
|
||||||
Units,
|
|
||||||
WeatherFeedItemType,
|
|
||||||
type WeatherKitClient,
|
|
||||||
type WeatherKitResponse,
|
|
||||||
} from "@aris/source-weatherkit"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import fixture from "../../../../packages/aris-source-weatherkit/fixtures/san-francisco.json"
|
|
||||||
import { WeatherService } from "./service.ts"
|
|
||||||
|
|
||||||
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
|
|
||||||
|
|
||||||
function createMockClient(response: WeatherKitResponse): WeatherKitClient {
|
|
||||||
return {
|
|
||||||
fetch: async () => response,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockContext(location?: { lat: number; lng: number }): Context {
|
|
||||||
const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") }
|
|
||||||
if (location) {
|
|
||||||
ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("WeatherService", () => {
|
|
||||||
test("feedSourceForUser creates source on first call", () => {
|
|
||||||
const service = new WeatherService({ client: mockClient })
|
|
||||||
const source = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(source).toBeDefined()
|
|
||||||
expect(source.id).toBe("aris.weather")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feedSourceForUser returns same source for same user", () => {
|
|
||||||
const service = new WeatherService({ client: mockClient })
|
|
||||||
const source1 = service.feedSourceForUser("user-1")
|
|
||||||
const source2 = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(source1).toBe(source2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feedSourceForUser returns different sources for different users", () => {
|
|
||||||
const service = new WeatherService({ client: mockClient })
|
|
||||||
const source1 = service.feedSourceForUser("user-1")
|
|
||||||
const source2 = service.feedSourceForUser("user-2")
|
|
||||||
|
|
||||||
expect(source1).not.toBe(source2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feedSourceForUser applies hourly and daily limits", async () => {
|
|
||||||
const service = new WeatherService({
|
|
||||||
client: mockClient,
|
|
||||||
hourlyLimit: 3,
|
|
||||||
dailyLimit: 2,
|
|
||||||
})
|
|
||||||
const source = service.feedSourceForUser("user-1")
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
|
|
||||||
const hourly = items.filter((i) => i.type === WeatherFeedItemType.hourly)
|
|
||||||
const daily = items.filter((i) => i.type === WeatherFeedItemType.daily)
|
|
||||||
|
|
||||||
expect(hourly).toHaveLength(3)
|
|
||||||
expect(daily).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("feedSourceForUser applies units", async () => {
|
|
||||||
const service = new WeatherService({
|
|
||||||
client: mockClient,
|
|
||||||
units: Units.imperial,
|
|
||||||
})
|
|
||||||
const source = service.feedSourceForUser("user-1")
|
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
|
||||||
|
|
||||||
const items = await source.fetchItems(context)
|
|
||||||
const current = items.find((i) => i.type === WeatherFeedItemType.current)
|
|
||||||
|
|
||||||
expect(current).toBeDefined()
|
|
||||||
// Fixture has ~15.87°C, imperial should be ~60.6°F
|
|
||||||
expect(current!.data.temperature).toBeGreaterThan(50)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removeUser removes the source", () => {
|
|
||||||
const service = new WeatherService({ client: mockClient })
|
|
||||||
service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
service.removeUser("user-1")
|
|
||||||
|
|
||||||
// After removal, feedSourceForUser should create a new instance
|
|
||||||
const source2 = service.feedSourceForUser("user-1")
|
|
||||||
expect(source2).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removeUser allows new source to be created", () => {
|
|
||||||
const service = new WeatherService({ client: mockClient })
|
|
||||||
const source1 = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
service.removeUser("user-1")
|
|
||||||
const source2 = service.feedSourceForUser("user-1")
|
|
||||||
|
|
||||||
expect(source1).not.toBe(source2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removeUser is no-op for unknown user", () => {
|
|
||||||
const service = new WeatherService({ client: mockClient })
|
|
||||||
|
|
||||||
expect(() => service.removeUser("unknown")).not.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit"
|
|
||||||
|
|
||||||
import type { FeedSourceProvider } from "../feed/service.ts"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options forwarded to every per-user WeatherSource.
|
|
||||||
* Must include either `credentials` or `client` (same requirement as WeatherSourceOptions).
|
|
||||||
*/
|
|
||||||
export type WeatherServiceOptions = WeatherSourceOptions
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages WeatherSource instances per user.
|
|
||||||
*/
|
|
||||||
export class WeatherService implements FeedSourceProvider {
|
|
||||||
private sources = new Map<string, WeatherSource>()
|
|
||||||
private readonly options: WeatherServiceOptions
|
|
||||||
|
|
||||||
constructor(options: WeatherServiceOptions) {
|
|
||||||
this.options = options
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create a WeatherSource for a user.
|
|
||||||
*/
|
|
||||||
feedSourceForUser(userId: string): WeatherSource {
|
|
||||||
let source = this.sources.get(userId)
|
|
||||||
if (!source) {
|
|
||||||
source = new WeatherSource(this.options)
|
|
||||||
this.sources.set(userId, source)
|
|
||||||
}
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a user's WeatherSource.
|
|
||||||
*/
|
|
||||||
removeUser(userId: string): void {
|
|
||||||
this.sources.delete(userId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"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.
@@ -8,6 +8,12 @@
|
|||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
|
"development-simulator": {
|
||||||
|
"extends": "development",
|
||||||
|
"ios": {
|
||||||
|
"simulator": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"build:ios": "eas build --profile development --platform ios --non-interactive",
|
"build:ios": "eas build --profile development --platform ios --non-interactive",
|
||||||
|
"build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
|
||||||
"debugger": "bun run scripts/open-debugger.ts"
|
"debugger": "bun run scripts/open-debugger.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
|
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
@@ -40,8 +42,10 @@
|
|||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.5.1",
|
||||||
|
"twrnc": "^4.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Platform } from "react-native"
|
import { Platform } from "react-native"
|
||||||
|
|||||||
93
bun.lock
93
bun.lock
@@ -21,8 +21,6 @@
|
|||||||
"@aris/source-location": "workspace:*",
|
"@aris/source-location": "workspace:*",
|
||||||
"@aris/source-tfl": "workspace:*",
|
"@aris/source-tfl": "workspace:*",
|
||||||
"@aris/source-weatherkit": "workspace:*",
|
"@aris/source-weatherkit": "workspace:*",
|
||||||
"@hono/trpc-server": "^0.3",
|
|
||||||
"@trpc/server": "^11",
|
|
||||||
"arktype": "^2.1.29",
|
"arktype": "^2.1.29",
|
||||||
"better-auth": "^1",
|
"better-auth": "^1",
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
@@ -37,6 +35,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
|
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
@@ -62,8 +61,10 @@
|
|||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
|
"twrnc": "^4.16.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
@@ -135,6 +136,8 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="],
|
"@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="],
|
||||||
|
|
||||||
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
"@aris/backend": ["@aris/backend@workspace:apps/aris-backend"],
|
"@aris/backend": ["@aris/backend@workspace:apps/aris-backend"],
|
||||||
|
|
||||||
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
||||||
@@ -381,6 +384,8 @@
|
|||||||
|
|
||||||
"@expo-google-fonts/inter": ["@expo-google-fonts/inter@0.4.2", "", {}, "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ=="],
|
"@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/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=="],
|
"@expo/bunyan": ["@expo/bunyan@4.0.1", "", { "dependencies": { "uuid": "^8.0.0" } }, "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg=="],
|
||||||
@@ -461,8 +466,6 @@
|
|||||||
|
|
||||||
"@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="],
|
"@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/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=="],
|
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
@@ -679,8 +682,6 @@
|
|||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@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/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
|
||||||
|
|
||||||
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
|
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
|
||||||
@@ -923,6 +924,10 @@
|
|||||||
|
|
||||||
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
||||||
|
|
||||||
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
|
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||||
|
|
||||||
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
|
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
|
||||||
|
|
||||||
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
|
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
|
||||||
@@ -955,6 +960,8 @@
|
|||||||
|
|
||||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||||
|
|
||||||
|
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
|
||||||
|
|
||||||
"cardinal": ["cardinal@2.1.1", "", { "dependencies": { "ansicolors": "~0.3.2", "redeyed": "~2.1.0" }, "bin": { "cdl": "./bin/cdl.js" } }, "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw=="],
|
"cardinal": ["cardinal@2.1.1", "", { "dependencies": { "ansicolors": "~0.3.2", "redeyed": "~2.1.0" }, "bin": { "cdl": "./bin/cdl.js" } }, "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw=="],
|
||||||
@@ -963,6 +970,8 @@
|
|||||||
|
|
||||||
"charenc": ["charenc@0.0.2", "", {}, "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="],
|
"charenc": ["charenc@0.0.2", "", {}, "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|
||||||
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
|
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
|
||||||
@@ -1025,6 +1034,14 @@
|
|||||||
|
|
||||||
"css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="],
|
"css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="],
|
||||||
|
|
||||||
|
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||||
|
|
||||||
|
"css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="],
|
||||||
|
|
||||||
|
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||||
|
|
||||||
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||||
@@ -1065,14 +1082,26 @@
|
|||||||
|
|
||||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
|
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||||
|
|
||||||
"diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
|
"diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
|
||||||
|
|
||||||
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
|
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
|
||||||
|
|
||||||
|
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
|
|
||||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||||
|
|
||||||
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
||||||
|
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||||
|
|
||||||
|
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
"domino": ["domino@2.1.6", "", {}, "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ=="],
|
"domino": ["domino@2.1.6", "", {}, "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ=="],
|
||||||
|
|
||||||
|
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@16.3.1", "", {}, "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ=="],
|
"dotenv": ["dotenv@16.3.1", "", {}, "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ=="],
|
||||||
|
|
||||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||||
@@ -1095,6 +1124,8 @@
|
|||||||
|
|
||||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
|
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"env-editor": ["env-editor@0.4.2", "", {}, "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA=="],
|
"env-editor": ["env-editor@0.4.2", "", {}, "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA=="],
|
||||||
|
|
||||||
"env-paths": ["env-paths@2.2.0", "", {}, "sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA=="],
|
"env-paths": ["env-paths@2.2.0", "", {}, "sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA=="],
|
||||||
@@ -1405,6 +1436,8 @@
|
|||||||
|
|
||||||
"is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
|
"is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
|
||||||
|
|
||||||
|
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
"is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
|
"is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
|
||||||
|
|
||||||
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
||||||
@@ -1501,6 +1534,8 @@
|
|||||||
|
|
||||||
"jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="],
|
"jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||||
|
|
||||||
"jks-js": ["jks-js@1.1.0", "", { "dependencies": { "node-forge": "^1.3.1", "node-int64": "^0.4.0", "node-rsa": "^1.1.1" } }, "sha512-irWi8S2V029Vic63w0/TYa8NIZwXu9oeMtHQsX51JDIVBo0lrEaOoyM8ALEEh5PVKD6TrA26FixQK6TzT7dHqA=="],
|
"jks-js": ["jks-js@1.1.0", "", { "dependencies": { "node-forge": "^1.3.1", "node-int64": "^0.4.0", "node-rsa": "^1.1.1" } }, "sha512-irWi8S2V029Vic63w0/TYa8NIZwXu9oeMtHQsX51JDIVBo0lrEaOoyM8ALEEh5PVKD6TrA26FixQK6TzT7dHqA=="],
|
||||||
|
|
||||||
"joi": ["joi@17.11.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0", "@hapi/topo": "^5.0.0", "@sideway/address": "^4.1.3", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ=="],
|
"joi": ["joi@17.11.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0", "@hapi/topo": "^5.0.0", "@sideway/address": "^4.1.3", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ=="],
|
||||||
@@ -1573,6 +1608,8 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
||||||
|
|
||||||
|
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
@@ -1603,6 +1640,8 @@
|
|||||||
|
|
||||||
"md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="],
|
"md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="],
|
||||||
|
|
||||||
|
"mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
|
||||||
|
|
||||||
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
|
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
|
||||||
|
|
||||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||||
@@ -1703,12 +1742,16 @@
|
|||||||
|
|
||||||
"npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="],
|
"npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="],
|
||||||
|
|
||||||
|
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||||
|
|
||||||
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
|
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
|
||||||
|
|
||||||
"ob1": ["ob1@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA=="],
|
"ob1": ["ob1@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA=="],
|
||||||
|
|
||||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
|
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||||
@@ -1795,6 +1838,8 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
|
"picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
|
||||||
|
|
||||||
|
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||||
|
|
||||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||||
|
|
||||||
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
|
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
|
||||||
@@ -1807,6 +1852,16 @@
|
|||||||
|
|
||||||
"postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
"postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
||||||
|
|
||||||
|
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||||
|
|
||||||
|
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||||
|
|
||||||
|
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||||
|
|
||||||
|
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||||
|
|
||||||
|
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||||
|
|
||||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
@@ -1875,6 +1930,8 @@
|
|||||||
|
|
||||||
"react-native-screens": ["react-native-screens@4.16.0", "", { "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q=="],
|
"react-native-screens": ["react-native-screens@4.16.0", "", { "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q=="],
|
||||||
|
|
||||||
|
"react-native-svg": ["react-native-svg@15.12.1", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g=="],
|
||||||
|
|
||||||
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
|
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
|
||||||
|
|
||||||
"react-native-worklets": ["react-native-worklets@0.5.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-classes": "^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", "@babel/plugin-transform-optional-chaining": "^7.0.0-0", "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w=="],
|
"react-native-worklets": ["react-native-worklets@0.5.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-classes": "^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", "@babel/plugin-transform-optional-chaining": "^7.0.0-0", "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w=="],
|
||||||
@@ -1887,6 +1944,10 @@
|
|||||||
|
|
||||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
"redeyed": ["redeyed@2.1.1", "", { "dependencies": { "esprima": "~4.0.0" } }, "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ=="],
|
"redeyed": ["redeyed@2.1.1", "", { "dependencies": { "esprima": "~4.0.0" } }, "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ=="],
|
||||||
|
|
||||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||||
@@ -2073,6 +2134,8 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||||
|
|
||||||
"tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
|
"tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
|
||||||
|
|
||||||
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
|
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
|
||||||
@@ -2123,6 +2186,8 @@
|
|||||||
|
|
||||||
"turndown": ["turndown@7.1.2", "", { "dependencies": { "domino": "^2.1.6" } }, "sha512-ntI9R7fcUKjqBP6QU8rBK2Ehyt8LAzt3UBT9JR9tgo6GtuKvyUzpayWmeMKJw1DPdXzktvtIT8m2mVXz+bL/Qg=="],
|
"turndown": ["turndown@7.1.2", "", { "dependencies": { "domino": "^2.1.6" } }, "sha512-ntI9R7fcUKjqBP6QU8rBK2Ehyt8LAzt3UBT9JR9tgo6GtuKvyUzpayWmeMKJw1DPdXzktvtIT8m2mVXz+bL/Qg=="],
|
||||||
|
|
||||||
|
"twrnc": ["twrnc@4.16.0", "", { "dependencies": { "tailwindcss": ">=2.0.0 <4.0.0" }, "peerDependencies": { "react-native": ">=0.62.2" } }, "sha512-sPSnSlT2CmOd2ITUm9M8ltsEjTyJti/9HpYfewAfhqRT4gMQtOWkK1tiIev0vVhPJ9IcTGtk2TC33OsSLhsFfA=="],
|
||||||
|
|
||||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
|
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
|
||||||
@@ -2177,6 +2242,8 @@
|
|||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||||
|
|
||||||
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||||
@@ -2499,6 +2566,8 @@
|
|||||||
|
|
||||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"chromium-edge-launcher/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
"chromium-edge-launcher/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
||||||
|
|
||||||
"chromium-edge-launcher/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
"chromium-edge-launcher/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
||||||
@@ -2513,6 +2582,8 @@
|
|||||||
|
|
||||||
"cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
"cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||||
|
|
||||||
|
"css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"dotenv-expand/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
"dotenv-expand/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||||
|
|
||||||
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||||
@@ -2635,6 +2706,8 @@
|
|||||||
|
|
||||||
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"postcss-import/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
|
|
||||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||||
|
|
||||||
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||||
@@ -2661,6 +2734,8 @@
|
|||||||
|
|
||||||
"react-style-singleton/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"react-style-singleton/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="],
|
"requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="],
|
||||||
|
|
||||||
"rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
"rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||||
@@ -2681,6 +2756,10 @@
|
|||||||
|
|
||||||
"supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
"supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
|
"tailwindcss/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
|
|
||||||
|
"tailwindcss/sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||||
|
|
||||||
"tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
"tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||||
|
|
||||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||||
@@ -2905,6 +2984,8 @@
|
|||||||
|
|
||||||
"sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
"sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||||
|
|
||||||
|
"tailwindcss/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@ Examples of feed items:
|
|||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
1. **Extensibility**: The core must support different data sources, including third-party sources.
|
1. **Extensibility**: The core must support different data sources, including third-party sources.
|
||||||
2. **Separation of concerns**: Core handles data only. UI rendering is a separate system.
|
2. **Separation of concerns**: Core handles data and UI description. The client is a thin renderer.
|
||||||
3. **Parallel execution**: Sources run in parallel; no inter-source dependencies.
|
3. **Dependency graph**: Sources declare dependencies on other sources. The engine resolves the graph and runs independent sources in parallel.
|
||||||
4. **Graceful degradation**: Failed sources are skipped; partial results are returned.
|
4. **Graceful degradation**: Failed sources are skipped; partial results are returned.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -25,26 +25,28 @@ Examples of feed items:
|
|||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Backend │
|
│ Backend │
|
||||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
│ │ aris-core │ │ Sources │ │ UI Registry │ │
|
│ │ aris-core │ │ Sources │ │
|
||||||
│ │ │ │ (plugins) │ │ (schemas from │ │
|
│ │ │ │ (plugins) │ │
|
||||||
│ │ - Reconciler│◄───│ - Calendar │ │ third parties)│ │
|
│ │ - FeedEngine│◄───│ - Calendar │ │
|
||||||
│ │ - Context │ │ - Weather │ │ │ │
|
│ │ - Context │ │ - Weather │ │
|
||||||
│ │ - FeedItem │ │ - Spotify │ │ │ │
|
│ │ - FeedItem │ │ - TfL │ │
|
||||||
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
|
│ │ - Actions │ │ - Spotify │ │
|
||||||
│ │ │ │
|
│ └─────────────┘ └─────────────┘ │
|
||||||
│ ▼ ▼ │
|
│ │ │
|
||||||
│ Feed (data only) UI Schemas (JSON) │
|
│ ▼ │
|
||||||
|
│ Feed items (data + ui trees + slots) │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
│ │
|
│
|
||||||
▼ ▼
|
▼ (WebSocket / JSON-RPC)
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Frontend │
|
│ Client (React Native) │
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
│ │ Renderer │ │
|
│ │ json-render + twrnc component map │ │
|
||||||
│ │ - Receives feed items │ │
|
│ │ - Receives feed items with ui trees │ │
|
||||||
│ │ - Fetches UI schema by item type │ │
|
│ │ - Renders using registered RN components + twrnc │ │
|
||||||
│ │ - Renders using json-render or similar │ │
|
│ │ - User interactions trigger source actions │ │
|
||||||
|
│ │ - Bespoke native components for rich interactions │ │
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -54,15 +56,16 @@ Examples of feed items:
|
|||||||
The core is responsible for:
|
The core is responsible for:
|
||||||
|
|
||||||
- Defining the context and feed item interfaces
|
- Defining the context and feed item interfaces
|
||||||
- Providing a reconciler that orchestrates data sources
|
- Providing a `FeedEngine` that orchestrates sources via a dependency graph
|
||||||
- Returning a flat list of prioritized feed items
|
- Returning a flat list of prioritized feed items
|
||||||
|
- Routing action execution to the correct source
|
||||||
|
|
||||||
### Key Concepts
|
### Key Concepts
|
||||||
|
|
||||||
- **Context**: Time and location (with accuracy) passed to all sources
|
- **Context**: Time and location (with accuracy) passed to all sources. Sources can contribute to context (e.g., location source provides coordinates, weather source provides conditions).
|
||||||
- **FeedItem**: Has an ID (source-generated, stable), type, priority, timestamp, and JSON-serializable data
|
- **FeedItem**: Has an ID (source-generated, stable), type, timestamp, JSON-serializable data, optional actions, an optional `ui` tree, and optional `slots` for LLM-fillable content.
|
||||||
- **DataSource**: Interface that third parties implement to provide feed items
|
- **FeedSource**: Interface that first and third parties implement to provide context, feed items, and actions. Uses reverse-domain IDs (e.g., `aris.weather`, `com.spotify`).
|
||||||
- **Reconciler**: Orchestrates sources, runs them in parallel, returns items and any errors
|
- **FeedEngine**: Orchestrates sources respecting their dependency graph, runs independent sources in parallel, returns items and any errors. Routes action execution to the correct source.
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
@@ -71,10 +74,13 @@ Key decisions:
|
|||||||
- Sources receive the full context and decide internally what to use
|
- Sources receive the full context and decide internally what to use
|
||||||
- Each source returns a single item type (e.g., separate "Calendar Source" and "Location Suggestion Source" rather than a combined "Google Source")
|
- Each source returns a single item type (e.g., separate "Calendar Source" and "Location Suggestion Source" rather than a combined "Google Source")
|
||||||
- Sources live in separate packages, not in the core
|
- Sources live in separate packages, not in the core
|
||||||
|
- Sources declare dependencies on other sources (e.g., weather depends on location)
|
||||||
- Sources are responsible for:
|
- Sources are responsible for:
|
||||||
- Transforming their domain data into feed items
|
- Transforming their domain data into feed items
|
||||||
- Assigning priority based on domain logic (e.g., "event starting in 10 minutes" = high priority)
|
- Assigning priority based on domain logic (e.g., "event starting in 10 minutes" = high priority)
|
||||||
- Returning empty arrays when nothing is relevant
|
- Returning empty arrays when nothing is relevant
|
||||||
|
- Providing a `ui` tree for each feed item
|
||||||
|
- Declaring and handling actions (e.g., RSVP, complete task, play/pause)
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@@ -83,28 +89,323 @@ Configuration is passed at source registration time, not per reconcile call. Sou
|
|||||||
## Feed Output
|
## Feed Output
|
||||||
|
|
||||||
- Flat list of `FeedItem` objects
|
- Flat list of `FeedItem` objects
|
||||||
- No UI information (no icons, card types, etc.)
|
- Items carry data, an optional `ui` field describing their layout, and optional `slots` for LLM enhancement
|
||||||
- Items are a discriminated union by `type` field
|
- Items are a discriminated union by `type` field
|
||||||
- Reconciler sorts by priority; can act as tiebreaker
|
|
||||||
|
|
||||||
## UI Rendering (Separate from Core)
|
## UI Rendering: Server-Driven UI
|
||||||
|
|
||||||
The core does not handle UI. For extensible third-party UI:
|
The UI for feed items is **server-driven**. Sources describe how their items look using a JSON tree (the `ui` field on `FeedItem`). The client renders these trees using [json-render](https://json-render.dev/) with a registered set of React Native components styled via [twrnc](https://github.com/jaredh159/tailwind-react-native-classnames).
|
||||||
|
|
||||||
1. Third-party apps register their UI schemas through the backend (UI Registry)
|
### How it works
|
||||||
2. Frontend fetches UI schemas from the backend
|
|
||||||
3. Frontend matches feed items to schemas by `type` and renders accordingly
|
|
||||||
|
|
||||||
This approach:
|
1. Sources return feed items with a `ui` field — a JSON tree describing the card layout using Tailwind class strings.
|
||||||
|
2. The client passes a component map to json-render. Each component wraps a React Native primitive and resolves `className` via twrnc.
|
||||||
|
3. json-render walks the tree and renders native components. twrnc parses Tailwind classes at runtime — no build step, arbitrary values work.
|
||||||
|
4. User interactions (tap, etc.) map to source actions via the `actions` field on `FeedItem`. The client sends action requests to the backend, which routes them to the correct source via `FeedEngine.executeAction()`.
|
||||||
|
|
||||||
- Keeps the core focused on data
|
### Styling
|
||||||
- Works across platforms (web, React Native)
|
|
||||||
- Avoids the need for third parties to inject code into the app
|
|
||||||
- Uses a json-render style approach for declarative UI from JSON schemas
|
|
||||||
|
|
||||||
Reference: https://github.com/vercel-labs/json-render
|
- Sources use Tailwind CSS class strings via the `className` prop (e.g., `"p-4 bg-white dark:bg-black rounded-xl"`).
|
||||||
|
- twrnc resolves classes to React Native style objects at runtime. Supports arbitrary values (`mt-[31px]`, `bg-[#eaeaea]`), dark mode (`dark:bg-black`), and platform prefixes (`ios:pt-4 android:pt-2`).
|
||||||
|
- Custom colors and spacing are configured via `tailwind.config.js` on the client.
|
||||||
|
- No compile-time constraint — all styles resolve at runtime.
|
||||||
|
|
||||||
|
### Two tiers of UI
|
||||||
|
|
||||||
|
- **Server-driven (default):** Any source can return a `ui` tree. Covers most cards — weather, tasks, alerts, package tracking, news, etc. Simple interactions go through source actions. This is the default path for both first-party and third-party sources.
|
||||||
|
- **Bespoke native:** For cards that need rich client interaction (gestures, animations, real-time updates), a native React Native component is registered in the json-render component map and referenced by type. Third parties that need this level of richness work with the ARIS team to get it integrated.
|
||||||
|
|
||||||
|
### Why server-driven
|
||||||
|
|
||||||
|
- Feed items are inherently server-driven — the data comes from sources on the backend. Attaching the layout alongside the data is a natural extension.
|
||||||
|
- Card designs can be updated without shipping an app update.
|
||||||
|
- Third-party sources can ship their own UI without bundling anything new into the app.
|
||||||
|
|
||||||
|
Reference: https://json-render.dev/
|
||||||
|
|
||||||
|
## Feed Items with UI and Slots
|
||||||
|
|
||||||
|
> Note: the codebase has evolved since the sections above. The engine now uses a dependency graph with topological ordering (`FeedEngine`, `FeedSource`), not the parallel reconciler described above. The `priority` field is being replaced by post-processing (see the ideas doc). This section describes the UI and enhancement architecture going forward.
|
||||||
|
|
||||||
|
Feed items carry an optional `ui` field containing a json-render tree, and an optional `slots` field for LLM-fillable content.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FeedItem<TType, TData> {
|
||||||
|
id: string
|
||||||
|
type: TType
|
||||||
|
timestamp: Date
|
||||||
|
data: TData
|
||||||
|
ui?: JsonRenderNode
|
||||||
|
slots?: Record<string, Slot>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Slot {
|
||||||
|
/** Tells the LLM what this slot wants — the source writes this */
|
||||||
|
description: string
|
||||||
|
/** LLM-filled text content, null until enhanced */
|
||||||
|
content: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
The source produces the item with a UI tree and empty slots:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Weather source produces:
|
||||||
|
{
|
||||||
|
id: "weather-current-123",
|
||||||
|
type: "weather-current",
|
||||||
|
data: { temperature: 18, condition: "cloudy" },
|
||||||
|
ui: {
|
||||||
|
component: "VStack",
|
||||||
|
children: [
|
||||||
|
{ component: "WeatherHeader", props: { temp: 18, condition: "cloudy" } },
|
||||||
|
{ component: "Slot", props: { name: "insight" } },
|
||||||
|
{ component: "HourlyChart", props: { hours: [...] } },
|
||||||
|
{ component: "Slot", props: { name: "cross-source" } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
"insight": {
|
||||||
|
description: "A short contextual insight about the current weather and how it affects the user's day",
|
||||||
|
content: null
|
||||||
|
},
|
||||||
|
"cross-source": {
|
||||||
|
description: "Connection between weather and the user's calendar events or plans",
|
||||||
|
content: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The LLM enhancement harness fills `content`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
slots: {
|
||||||
|
"insight": {
|
||||||
|
description: "...",
|
||||||
|
content: "Rain after 3pm — grab a jacket before your walk"
|
||||||
|
},
|
||||||
|
"cross-source": {
|
||||||
|
description: "...",
|
||||||
|
content: "Should be dry by 7pm for your dinner at The Ivy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The client renders the `ui` tree. When it hits a `Slot` node, it looks up `slots[name].content`. If non-null, render the text. If null, render nothing.
|
||||||
|
|
||||||
|
### Separation of concerns
|
||||||
|
|
||||||
|
- **Sources** own the UI layout and declare what slots exist with descriptions.
|
||||||
|
- **The LLM** fills slot content. It doesn't know about layout or positioning.
|
||||||
|
- **The client** renders the UI tree and resolves slots to their content.
|
||||||
|
|
||||||
|
Sources define the prompt for each slot via the `description` field. The harness doesn't need to know what slots any source type has — it reads them dynamically from the items.
|
||||||
|
|
||||||
|
Each source defines its own slots. The harness handles them automatically — no central registry needed.
|
||||||
|
|
||||||
|
## Enhancement Harness
|
||||||
|
|
||||||
|
The LLM enhancement harness fills slots and produces synthetic feed items. It runs reactively — triggered by context changes, not by a timer.
|
||||||
|
|
||||||
|
### Execution model
|
||||||
|
|
||||||
|
```
|
||||||
|
FeedEngine.refresh()
|
||||||
|
→ sources produce items with ui + empty slots
|
||||||
|
↓
|
||||||
|
Fast path (rule-based post-processors, <10ms)
|
||||||
|
→ group, dedup, affinity, time-adjust
|
||||||
|
→ merge LAST cached slot fills + synthetic items
|
||||||
|
→ return feed to UI immediately
|
||||||
|
↓
|
||||||
|
Background: has context changed since last LLM run?
|
||||||
|
(hash of: item IDs + data + slot descriptions + user memory)
|
||||||
|
↓
|
||||||
|
No → done, cache is still valid
|
||||||
|
Yes → run LLM harness async
|
||||||
|
→ fill slots + generate synthetic items
|
||||||
|
→ cache result
|
||||||
|
→ push updated feed to UI via WebSocket
|
||||||
|
```
|
||||||
|
|
||||||
|
The user never waits for the LLM. They see the feed instantly with the previous enhancement applied. If the LLM produces new slot content or synthetic items, the feed updates in place.
|
||||||
|
|
||||||
|
### LLM input
|
||||||
|
|
||||||
|
The harness serializes items with their unfilled slots into a single prompt. Items without slots are excluded. The LLM sees everything at once and fills whatever slots are relevant.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildHarnessInput(
|
||||||
|
items: FeedItem[],
|
||||||
|
context: AgentContext,
|
||||||
|
): HarnessInput {
|
||||||
|
const itemsWithSlots = items
|
||||||
|
.filter(item => item.slots && Object.keys(item.slots).length > 0)
|
||||||
|
.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
data: item.data,
|
||||||
|
slots: Object.fromEntries(
|
||||||
|
Object.entries(item.slots!).map(
|
||||||
|
([name, slot]) => [name, slot.description]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: itemsWithSlots,
|
||||||
|
userMemory: context.preferences,
|
||||||
|
currentTime: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The LLM sees:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "weather-current-123",
|
||||||
|
"type": "weather-current",
|
||||||
|
"data": { "temperature": 18, "condition": "cloudy" },
|
||||||
|
"slots": {
|
||||||
|
"insight": "A short contextual insight about the current weather and how it affects the user's day",
|
||||||
|
"cross-source": "Connection between weather and the user's calendar events or plans"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "calendar-event-456",
|
||||||
|
"type": "calendar-event",
|
||||||
|
"data": { "title": "Dinner at The Ivy", "startTime": "19:00", "location": "The Ivy, West St" },
|
||||||
|
"slots": {
|
||||||
|
"context": "Background on this event, attendees, or previous meetings with these people",
|
||||||
|
"logistics": "Travel time, parking, directions to the venue",
|
||||||
|
"weather": "Weather conditions relevant to this event's time and location"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"userMemory": { "commute": "victoria-line", "preference.walking_distance": "1 mile" },
|
||||||
|
"currentTime": "2025-02-26T14:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### LLM output
|
||||||
|
|
||||||
|
A flat map of item ID → slot name → text content. Slots left null are unfilled.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slotFills": {
|
||||||
|
"weather-current-123": {
|
||||||
|
"insight": "Rain after 3pm — grab a jacket before your walk",
|
||||||
|
"cross-source": "Should be dry by 7pm for your dinner at The Ivy"
|
||||||
|
},
|
||||||
|
"calendar-event-456": {
|
||||||
|
"context": null,
|
||||||
|
"logistics": "20-minute walk from home — leave by 18:40",
|
||||||
|
"weather": "Rain clears by evening, you'll be fine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syntheticItems": [
|
||||||
|
{
|
||||||
|
"id": "briefing-morning",
|
||||||
|
"type": "briefing",
|
||||||
|
"data": {},
|
||||||
|
"ui": { "component": "Text", "props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"suppress": [],
|
||||||
|
"rankingHints": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enhancement manager
|
||||||
|
|
||||||
|
One per user, living in the `FeedEngineManager` on the backend:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class EnhancementManager {
|
||||||
|
private cache: EnhancementResult | null = null
|
||||||
|
private lastInputHash: string | null = null
|
||||||
|
private running = false
|
||||||
|
|
||||||
|
async enhance(
|
||||||
|
items: FeedItem[],
|
||||||
|
context: AgentContext,
|
||||||
|
): Promise<EnhancementResult> {
|
||||||
|
const hash = computeHash(items, context)
|
||||||
|
|
||||||
|
if (hash === this.lastInputHash && this.cache) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.running) {
|
||||||
|
return this.cache ?? emptyResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true
|
||||||
|
this.runHarness(items, context)
|
||||||
|
.then(result => {
|
||||||
|
this.cache = result
|
||||||
|
this.lastInputHash = hash
|
||||||
|
this.notifySubscribers(result)
|
||||||
|
})
|
||||||
|
.finally(() => { this.running = false })
|
||||||
|
|
||||||
|
return this.cache ?? emptyResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnhancementResult {
|
||||||
|
slotFills: Record<string, Record<string, string | null>>
|
||||||
|
syntheticItems: FeedItem[]
|
||||||
|
suppress: string[]
|
||||||
|
rankingHints: Record<string, number>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Merging
|
||||||
|
|
||||||
|
After the harness runs, the engine merges slot fills into items:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function mergeEnhancement(
|
||||||
|
items: FeedItem[],
|
||||||
|
result: EnhancementResult,
|
||||||
|
): FeedItem[] {
|
||||||
|
return items.map(item => {
|
||||||
|
const fills = result.slotFills[item.id]
|
||||||
|
if (!fills || !item.slots) return item
|
||||||
|
|
||||||
|
const mergedSlots = { ...item.slots }
|
||||||
|
for (const [name, content] of Object.entries(fills)) {
|
||||||
|
if (name in mergedSlots && content !== null) {
|
||||||
|
mergedSlots[name] = { ...mergedSlots[name], content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...item, slots: mergedSlots }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cost control
|
||||||
|
|
||||||
|
- **Hash-based cache gate.** Most refreshes reuse the cached result.
|
||||||
|
- **Debounce.** Rapid context changes (location updates) settle before triggering a run.
|
||||||
|
- **Skip inactive users.** Don't run if the user hasn't opened the app in 2+ hours.
|
||||||
|
- **Exclude slotless items.** Only items with slots are sent to the LLM.
|
||||||
|
- **Text-only output.** Slots produce strings, not UI trees — fewer output tokens, less variance.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Exact schema format for UI registry
|
- How third parties authenticate/register their sources
|
||||||
- How third parties authenticate/register their sources and UI schemas
|
- Exact set of React Native components exposed in the json-render component map
|
||||||
|
- Validation/sandboxing of third-party ui trees
|
||||||
|
- How synthetic items define their UI (full json-render tree vs. registered component)
|
||||||
|
- Should slots support rich content (json-render nodes) in the future, or stay text-only?
|
||||||
|
- How to handle slot content that references other items (e.g., "your dinner at The Ivy" linking to the calendar card)
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ interface FeedSource<TItem extends FeedItem = FeedItem> {
|
|||||||
|
|
||||||
### Changes to FeedItem
|
### Changes to FeedItem
|
||||||
|
|
||||||
One optional field added.
|
Optional fields added for actions, server-driven UI, and LLM slots.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface FeedItem<
|
interface FeedItem<
|
||||||
@@ -140,6 +140,12 @@ interface FeedItem<
|
|||||||
|
|
||||||
/** Actions the user can take on this item. */
|
/** Actions the user can take on this item. */
|
||||||
actions?: readonly ItemAction[]
|
actions?: readonly ItemAction[]
|
||||||
|
|
||||||
|
/** Server-driven UI tree rendered by json-render on the client. */
|
||||||
|
ui?: JsonRenderNode
|
||||||
|
|
||||||
|
/** Named slots for LLM-fillable content. See architecture-draft.md. */
|
||||||
|
slots?: Record<string, Slot>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -222,6 +228,25 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
|
|||||||
{ actionId: "skip-track" },
|
{ actionId: "skip-track" },
|
||||||
{ actionId: "like-track", params: { trackId: track.id } },
|
{ actionId: "like-track", params: { trackId: track.id } },
|
||||||
],
|
],
|
||||||
|
ui: {
|
||||||
|
type: "View",
|
||||||
|
className: "flex-row items-center p-3 gap-3 bg-white dark:bg-black rounded-xl",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "Image",
|
||||||
|
source: { uri: track.albumArt },
|
||||||
|
className: "w-12 h-12 rounded-lg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "View",
|
||||||
|
className: "flex-1",
|
||||||
|
children: [
|
||||||
|
{ type: "Text", className: "font-semibold text-black dark:text-white", text: track.name },
|
||||||
|
{ type: "Text", className: "text-sm text-gray-500 dark:text-gray-400", text: track.artist },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -236,6 +261,8 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
|
|||||||
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
|
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
|
||||||
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
|
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
|
||||||
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
|
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
|
||||||
|
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI
|
||||||
|
6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
|
||||||
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
|
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
|
||||||
8. `FeedEngine.listActions()` aggregates actions from all sources
|
8. `FeedEngine.listActions()` aggregates actions from all sources
|
||||||
9. Existing tests pass unchanged (all changes are additive)
|
9. Existing tests pass unchanged (all changes are additive)
|
||||||
|
|||||||
@@ -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
|
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 {
|
interface SourceGraph {
|
||||||
sources: Map<string, FeedSource>
|
sources: Map<string, FeedSource>
|
||||||
sorted: FeedSource[]
|
sorted: FeedSource[]
|
||||||
@@ -59,6 +67,29 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
private cleanups: Array<() => void> = []
|
private cleanups: Array<() => void> = []
|
||||||
private started = false
|
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.
|
* Registers a FeedSource. Invalidates the cached graph.
|
||||||
*/
|
*/
|
||||||
@@ -124,7 +155,10 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
|
|
||||||
this.context = context
|
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.
|
* Sources with onContextUpdate will trigger re-computation of dependents.
|
||||||
*/
|
*/
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -168,13 +202,16 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
this.cleanups.push(cleanup)
|
this.cleanups.push(cleanup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.scheduleNextRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops all reactive subscriptions.
|
* Stops all reactive subscriptions and the periodic refresh timer.
|
||||||
*/
|
*/
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.started = false
|
this.started = false
|
||||||
|
this.cancelScheduledRefresh()
|
||||||
for (const cleanup of this.cleanups) {
|
for (const cleanup of this.cleanups) {
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
@@ -279,11 +316,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
|
|
||||||
items.sort((a, b) => b.priority - a.priority)
|
items.sort((a, b) => b.priority - a.priority)
|
||||||
|
|
||||||
this.notifySubscribers({
|
const result: FeedResult<TItems> = {
|
||||||
context: this.context,
|
context: this.context,
|
||||||
items: items as TItems[],
|
items: items as TItems[],
|
||||||
errors,
|
errors,
|
||||||
})
|
}
|
||||||
|
this.updateCache(result)
|
||||||
|
|
||||||
|
this.notifySubscribers(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectDependents(sourceId: string, graph: SourceGraph): string[] {
|
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)
|
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 {
|
private scheduleRefresh(): void {
|
||||||
// Simple immediate refresh for now - could add debouncing later
|
// Simple immediate refresh for now - could add debouncing later
|
||||||
this.refresh().then((result) => {
|
this.refresh()
|
||||||
this.notifySubscribers(result)
|
.then((result) => {
|
||||||
})
|
this.notifySubscribers(result)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Reactive refresh errors are non-fatal
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifySubscribers(result: FeedResult<TItems>): void {
|
private notifySubscribers(result: FeedResult<TItems>): void {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type { FeedItem } from "./feed"
|
|||||||
export type { FeedSource } from "./feed-source"
|
export type { FeedSource } from "./feed-source"
|
||||||
|
|
||||||
// Feed Engine
|
// Feed Engine
|
||||||
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
|
||||||
export { FeedEngine } from "./feed-engine"
|
export { FeedEngine } from "./feed-engine"
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user