Compare commits

..

18 Commits

Author SHA1 Message Date
3d492a5d56 Merge pull request #35 from kennethnym/feat/feed-engine-cache
feat: add caching to FeedEngine
2026-02-24 01:21:20 +00:00
08dd437952 feat: add caching to FeedEngine
Add lastFeed() method that returns cached FeedResult within
a configurable TTL (default 5 min). refresh() always fetches
fresh data and updates the cache. Periodic auto-refresh via
recursive setTimeout when engine is started. Reactive updates
reset the timer to avoid redundant fetches.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-24 01:13:41 +00:00
2fc20759dd Merge pull request #34 from kennethnym/refactor/remove-trpc
refactor: remove tRPC, use plain Hono routes
2026-02-22 21:10:16 +00:00
963bf073d1 fix: handle malformed JSON in location handler
Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 21:03:58 +00:00
c0b3db0e11 Update apps/aris-backend/src/location/http.ts
Co-authored-by: ona-patrol <ona@nym.sh>
2026-02-22 21:02:44 +00:00
ca4a337dcd refactor: remove tRPC, use plain Hono routes
Replace tRPC location.update mutation with POST /api/location
using Hono route + requireSession middleware. Extract auth
types (AuthUser, AuthToken) into auth/session.ts. Inject
sessionManager via Hono context local to location handlers.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 20:59:19 +00:00
769e2d4eb0 Merge pull request #33 from kennethnym/feat/source-serif-4-font
Add Source Serif 4 font to aris-client
2026-02-22 19:45:19 +00:00
5e9094710d fix(client): match Source Serif 4 family name
Use 'Source Serif 4' (with spaces) as the Android fontFamily
to match the iOS font metadata, avoiding Platform.select.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:44:27 +00:00
5556f3fbf9 fix(client): specify font weight and style
Use the object syntax with fontFamily, weight, and style
for Android. iOS uses flat paths and reads metadata from
the font files directly.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:09:55 +00:00
0176979925 feat(client): embed Inter font assets
Copy Inter font files to assets/fonts/ and add them
to the expo-font config plugin alongside Source Serif 4.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:05:01 +00:00
971aba0932 fix(client): use local font assets for EAS
Bun symlinks in node_modules don't resolve on EAS builds.
Copy font files to assets/fonts/ and reference them directly.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 19:03:23 +00:00
68e319e4b8 feat(client): add Source Serif 4 font
Install @expo-google-fonts/source-serif-4 and configure
expo-font plugin in app.json to embed all weights.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 18:42:50 +00:00
c042af88f3 Merge pull request #32 from kennethnym/dev/ios-simulator-build
dev(client): add ios simulator build
2026-02-22 17:54:01 +00:00
0608f2ac61 dev(client): add ios simulator build 2026-02-22 17:53:13 +00:00
1ade63dd8c Merge pull request #31 from kennethnym/feat/react-native-svg
feat(client): add react-native-svg
2026-02-22 00:18:23 +00:00
8df340d9af Merge remote-tracking branch 'origin/master' into feat/react-native-svg
# Conflicts:
#	bun.lock

Co-authored-by: Ona <no-reply@ona.com>
2026-02-22 00:15:43 +00:00
727280e8b1 feat(client): add react-native-svg 2026-02-22 00:13:17 +00:00
d30f70494b Merge pull request #30 from kennethnym/feat/replace-tamagui-with-twrnc
Replace Tamagui with twrnc
2026-02-21 23:53:42 +00:00
51 changed files with 574 additions and 125 deletions

View File

@@ -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",

View File

@@ -1,13 +1,16 @@
import type { Context, Next } from "hono" import type { Context, 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
}
declare module "hono" {
interface ContextVariableMap extends SessionVariables {}
} }
/** /**
@@ -48,7 +51,7 @@ 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
} }

View 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

View 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)
}

View File

@@ -1,28 +0,0 @@
import { type } from "arktype"
import type { UserSessionManager } from "../session/index.ts"
import type { TRPC } from "../trpc/router.ts"
const locationInput = type({
lat: "number",
lng: "number",
accuracy: "number",
timestamp: "Date",
})
export function createLocationRouter(
t: TRPC,
{ sessionManager }: { sessionManager: UserSessionManager },
) {
return t.router({
update: t.procedure.input(locationInput).mutation(async ({ input, ctx }) => {
const session = sessionManager.getOrCreate(ctx.user.id)
await session.engine.executeAction("aris.location", "update-location", {
lat: input.lat,
lng: input.lng,
accuracy: input.accuracy,
timestamp: input.timestamp,
})
}),
})
}

View File

@@ -1,12 +1,10 @@
import { LocationSource } from "@aris/source-location" import { LocationSource } from "@aris/source-location"
import { trpcServer } from "@hono/trpc-server"
import { Hono } from "hono" import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts" import { registerAuthHandlers } from "./auth/http.ts"
import { registerLocationHttpHandlers } from "./location/http.ts"
import { UserSessionManager } from "./session/index.ts" import { UserSessionManager } from "./session/index.ts"
import { WeatherSourceProvider } from "./weather/provider.ts" import { WeatherSourceProvider } from "./weather/provider.ts"
import { createContext } from "./trpc/context.ts"
import { createTRPCRouter } from "./trpc/router.ts"
function main() { function main() {
const sessionManager = new UserSessionManager([ const sessionManager = new UserSessionManager([
@@ -21,21 +19,12 @@ function main() {
}), }),
]) ])
const trpcRouter = createTRPCRouter({ sessionManager })
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)
registerLocationHttpHandlers(app, { sessionManager })
app.use(
"/trpc/*",
trpcServer({
router: trpcRouter,
createContext,
}),
)
return app return app
} }

View File

@@ -1,11 +1,11 @@
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
import { LocationSource } from "@aris/source-location" import { LocationSource } from "@aris/source-location"
import { describe, expect, mock, test } from "bun:test" import { describe, expect, mock, test } from "bun:test"
import { WeatherSourceProvider } from "../weather/provider.ts" import { WeatherSourceProvider } from "../weather/provider.ts"
import { UserSessionManager } from "./user-session-manager.ts" import { UserSessionManager } from "./user-session-manager.ts"
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
const mockWeatherClient: WeatherKitClient = { const mockWeatherClient: WeatherKitClient = {
fetch: async () => ({}) as WeatherKitResponse, fetch: async () => ({}) as WeatherKitResponse,
} }

View File

@@ -1,4 +1,5 @@
import type { FeedSourceProviderInput } from "./feed-source-provider.ts" import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
import { UserSession } from "./user-session.ts" import { UserSession } from "./user-session.ts"
export class UserSessionManager { export class UserSessionManager {

View File

@@ -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>>

View File

@@ -1,44 +0,0 @@
import { initTRPC, TRPCError } from "@trpc/server"
import type { UserSessionManager } from "../session/index.ts"
import type { Context } from "./context.ts"
import { createLocationRouter } from "../location/router.ts"
export type TRPC = ReturnType<typeof createTRPC>
export interface TRPCRouterDeps {
sessionManager: UserSessionManager
}
export function createTRPCRouter({ sessionManager }: TRPCRouterDeps) {
const t = createTRPC()
return t.router({
location: createLocationRouter(t, { sessionManager }),
})
}
export type TRPCRouter = ReturnType<typeof createTRPCRouter>
function createTRPC() {
const t = initTRPC.context<Context>().create()
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user || !ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED" })
}
return next({
ctx: {
user: ctx.user,
session: ctx.session,
},
})
})
return {
router: t.router,
procedure: t.procedure.use(isAuthed),
}
}

View File

@@ -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,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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"
}, },

View File

@@ -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,6 +42,7 @@
"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" "twrnc": "^4.16.0"

View File

@@ -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,6 +61,7 @@
"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", "twrnc": "^4.16.0",
@@ -384,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=="],
@@ -464,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=="],
@@ -682,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=="],
@@ -928,6 +926,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "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=="],
@@ -1034,6 +1034,12 @@
"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=="], "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=="],
@@ -1086,8 +1092,16 @@
"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=="],
@@ -1110,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=="],
@@ -1624,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=="],
@@ -1724,6 +1742,8 @@
"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=="],
@@ -1910,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=="],
@@ -2560,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=="],

View File

@@ -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()
})
})
}) })

View File

@@ -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 {

View File

@@ -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"
// ============================================================================= // =============================================================================