Compare commits

..

1 Commits

Author SHA1 Message Date
0145976a9f feat(backend): wire feed renderers into API
Add FeedRenderer and FeedRendererProvider to support server-side
rendering of feed items via ?render=json-render query param.

- FeedRenderer maps sourceId to FeedItemRenderer, renders matching
  items and drops the rest
- FeedRendererProvider mirrors FeedSourceProvider pattern for
  per-user renderer construction
- UserSession exposes renderer, handler converts JrxNode to Spec
- Returns 400 for unknown render format, 500 if renderer missing

Co-authored-by: Ona <no-reply@ona.com>
2026-03-15 17:07:33 +00:00
50 changed files with 586 additions and 3120 deletions

View File

@@ -6,14 +6,3 @@ services:
- postDevcontainerStart
commands:
start: cd apps/aelis-client && ./scripts/run-dev-server.sh
drizzle-studio:
name: Drizzle Studio
description: Drizzle Studio database browser for aelis-backend
triggeredBy:
- manual
commands:
start: |
FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||')
echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443"
cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983

View File

@@ -4,9 +4,6 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris
# BetterAuth secret (min 32 chars, generate with: openssl rand -base64 32)
BETTER_AUTH_SECRET=
# Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32)
CREDENTIALS_ENCRYPTION_KEY=
# Base URL of the backend
BETTER_AUTH_URL=http://localhost:3000

View File

@@ -1,20 +0,0 @@
// Used by Better Auth CLI for schema generation.
// Run: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { admin } from "better-auth/plugins"
import { SQL } from "bun"
import { drizzle } from "drizzle-orm/bun-sql"
const client = new SQL({ url: process.env.DATABASE_URL })
const db = drizzle({ client })
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
emailAndPassword: {
enabled: true,
},
plugins: [admin()],
})
export default auth

View File

@@ -1,10 +0,0 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
})

View File

@@ -1,66 +0,0 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "user_sources" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"source_id" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb,
"credentials" "bytea",
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_sources_user_id_source_id_unique" UNIQUE("user_id","source_id")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_sources" ADD CONSTRAINT "user_sources_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");

View File

@@ -1 +0,0 @@
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");

View File

@@ -1,457 +0,0 @@
{
"id": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_sources": {
"name": "user_sources",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_id": {
"name": "source_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"credentials": {
"name": "credentials",
"type": "bytea",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"user_sources_user_id_user_id_fk": {
"name": "user_sources_user_id_user_id_fk",
"tableFrom": "user_sources",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_sources_user_id_source_id_unique": {
"name": "user_sources_user_id_source_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"source_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,479 +0,0 @@
{
"id": "d963322c-77e2-4ac9-bd3c-ca544c85ae35",
"prevId": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_sources": {
"name": "user_sources",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_id": {
"name": "source_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"credentials": {
"name": "credentials",
"type": "bytea",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"user_sources_user_id_enabled_idx": {
"name": "user_sources_user_id_enabled_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "enabled",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_sources_user_id_user_id_fk": {
"name": "user_sources_user_id_user_id_fk",
"tableFrom": "user_sources",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_sources_user_id_source_id_unique": {
"name": "user_sources_user_id_source_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"source_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,20 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1773620066366,
"tag": "0000_wakeful_scorpion",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1773624297794,
"tag": "0001_misty_white_tiger",
"breakpoints": true
}
]
}

View File

@@ -6,13 +6,7 @@
"scripts": {
"dev": "bun run --watch src/server.ts",
"start": "bun run src/server.ts",
"test": "bun test src/",
"db:generate": "bunx drizzle-kit generate",
"db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y",
"db:push": "bunx drizzle-kit push",
"db:migrate": "bunx drizzle-kit migrate",
"db:studio": "bunx drizzle-kit studio",
"create-admin": "bun run src/scripts/create-admin.ts"
"test": "bun test src/"
},
"dependencies": {
"@aelis/core": "workspace:*",
@@ -21,13 +15,14 @@
"@aelis/source-location": "workspace:*",
"@aelis/source-tfl": "workspace:*",
"@aelis/source-weatherkit": "workspace:*",
"@nym.sh/jrx": "^0.2.0",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1",
"drizzle-orm": "^0.45.1",
"hono": "^4"
"hono": "^4",
"pg": "^8"
},
"devDependencies": {
"drizzle-kit": "^0.31.9"
"@types/pg": "^8"
}
}

View File

@@ -1,7 +1,7 @@
import type { Hono } from "hono"
import type { Auth } from "./index.ts"
import { auth } from "./index.ts"
export function registerAuthHandlers(app: Hono, auth: Auth): void {
export function registerAuthHandlers(app: Hono): void {
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
}

View File

@@ -1,26 +1,10 @@
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { admin } from "better-auth/plugins"
import type { Database } from "../db/index.ts"
import { pool } from "../db.ts"
import * as schema from "../db/schema.ts"
export function createAuth(db: Database) {
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is not set")
}
return betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
export const auth = betterAuth({
database: pool,
emailAndPassword: {
enabled: true,
},
plugins: [admin()],
})
}
export type Auth = ReturnType<typeof createAuth>
})

View File

@@ -1,8 +1,9 @@
import type { Context, MiddlewareHandler, Next } from "hono"
import type { Auth } from "./index.ts"
import type { AuthSession, AuthUser } from "./session.ts"
import { auth } from "./index.ts"
export interface SessionVariables {
user: AuthUser | null
session: AuthSession | null
@@ -17,11 +18,10 @@ declare module "hono" {
}
/**
* Creates a middleware that attaches session and user to the context.
* Does not reject unauthenticated requests - use createRequireSession for that.
* Middleware that attaches session and user to the context.
* Does not reject unauthenticated requests - use requireSession for that.
*/
export function createSessionMiddleware(auth: Auth): AuthSessionMiddleware {
return async (c: Context, next: Next): Promise<void> => {
export async function sessionMiddleware(c: Context, next: Next): Promise<void> {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (session) {
@@ -33,14 +33,12 @@ export function createSessionMiddleware(auth: Auth): AuthSessionMiddleware {
}
await next()
}
}
/**
* Creates a middleware that requires a valid session. Returns 401 if not authenticated.
* Middleware that requires a valid session. Returns 401 if not authenticated.
*/
export function createRequireSession(auth: Auth): AuthSessionMiddleware {
return async (c: Context, next: Next): Promise<Response | void> => {
export async function requireSession(c: Context, next: Next): Promise<Response | void> {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (!session) {
@@ -50,17 +48,16 @@ export function createRequireSession(auth: Auth): AuthSessionMiddleware {
c.set("user", session.user)
c.set("session", session.session)
await next()
}
}
/**
* Creates a function to get session from headers. Useful for WebSocket upgrade validation.
* Get session from headers. Useful for WebSocket upgrade validation.
*/
export function createGetSessionFromHeaders(auth: Auth) {
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
export async function getSessionFromHeaders(
headers: Headers,
): Promise<{ user: AuthUser; session: AuthSession } | null> {
const session = await auth.api.getSession({ headers })
return session
}
}
/**
@@ -84,10 +81,6 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
image: null,
createdAt: now,
updatedAt: now,
role: "admin",
banned: false,
banReason: null,
banExpires: null,
}
const session: AuthSession = {

View File

@@ -1,4 +1,4 @@
import type { Auth } from "./index.ts"
import type { auth } from "./index.ts"
export type AuthUser = Auth["$Infer"]["Session"]["user"]
export type AuthSession = Auth["$Infer"]["Session"]["session"]
export type AuthUser = typeof auth.$Infer.Session.user
export type AuthSession = typeof auth.$Infer.Session.session

View File

@@ -0,0 +1,7 @@
import type { FeedItemRenderer } from "@aelis/core"
import { renderCalDavFeedItem } from "@aelis/source-caldav"
export const CALDAV_SOURCE_ID = "aelis.caldav"
export const calDavRenderer: FeedItemRenderer = renderCalDavFeedItem as FeedItemRenderer

View File

@@ -0,0 +1,5 @@
import { Pool } from "pg"
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})

View File

@@ -1,96 +0,0 @@
import { relations } from "drizzle-orm"
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
})
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
},
(table) => [index("session_userId_idx").on(table.userId)],
)
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
)
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
)
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}))
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}))
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}))

View File

@@ -1,23 +0,0 @@
import { SQL } from "bun"
import { drizzle, type BunSQLDatabase } from "drizzle-orm/bun-sql"
import * as schema from "./schema.ts"
export type Database = BunSQLDatabase<typeof schema>
export interface DatabaseConnection {
db: Database
close: () => Promise<void>
}
export function createDatabase(url: string): DatabaseConnection {
if (!url) {
throw new Error("DATABASE_URL is required")
}
const client = new SQL({ url })
return {
db: drizzle({ client, schema }),
close: () => client.close(),
}
}

View File

@@ -1,62 +0,0 @@
import {
boolean,
customType,
index,
jsonb,
pgTable,
text,
timestamp,
unique,
uuid,
} from "drizzle-orm/pg-core"
// ---------------------------------------------------------------------------
// Better Auth core tables
// Re-exported from CLI-generated schema.
// Regenerate with: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts
// ---------------------------------------------------------------------------
export {
user,
session,
account,
verification,
userRelations,
sessionRelations,
accountRelations,
} from "./auth-schema.ts"
import { user } from "./auth-schema.ts"
// ---------------------------------------------------------------------------
// AELIS — per-user source configuration
// ---------------------------------------------------------------------------
const bytea = customType<{ data: Buffer }>({
dataType() {
return "bytea"
},
})
export const userSources = pgTable(
"user_sources",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
sourceId: text("source_id").notNull(),
enabled: boolean("enabled").notNull().default(true),
config: jsonb("config").default({}),
credentials: bytea("credentials"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at")
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => [
unique("user_sources_user_id_source_id_unique").on(t.userId, t.sourceId),
index("user_sources_user_id_enabled_idx").on(t.userId, t.enabled),
],
)

View File

@@ -1,10 +1,19 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import type {
ActionDefinition,
ContextEntry,
FeedItem,
FeedItemRenderer,
FeedSource,
} from "@aelis/core"
import type { Spec } from "@json-render/core"
import { contextKey } from "@aelis/core"
import { describe, expect, spyOn, test } from "bun:test"
import { JRX_NODE } from "@nym.sh/jrx"
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { FeedRenderer } from "../session/feed-renderer.ts"
import { UserSessionManager } from "../session/index.ts"
import { registerFeedHttpHandlers } from "./http.ts"
@@ -19,6 +28,17 @@ interface FeedResponse {
errors: Array<{ sourceId: string; error: string }>
}
interface RenderedFeedResponse {
items: Array<{
id: string
type: string
timestamp: string
data: Record<string, unknown>
ui: Spec
}>
errors: Array<{ sourceId: string; error: string }>
}
function createStubSource(
id: string,
items: FeedItem[] = [],
@@ -72,19 +92,12 @@ describe("GET /api/feed", () => {
},
]
const manager = new UserSessionManager({
providers: [
{
sourceId: "test",
async feedSourceForUser() {
return createStubSource("test", items)
},
},
],
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
// Prime the cache
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
await session.engine.refresh()
expect(session.engine.lastFeed()).not.toBeNull()
@@ -112,14 +125,7 @@ describe("GET /api/feed", () => {
},
]
const manager = new UserSessionManager({
providers: [
{
sourceId: "test",
async feedSourceForUser() {
return createStubSource("test", items)
},
},
],
providers: [() => createStubSource("test", items)],
})
const app = buildTestApp(manager, "user-1")
@@ -150,16 +156,7 @@ describe("GET /api/feed", () => {
throw new Error("connection timeout")
},
}
const manager = new UserSessionManager({
providers: [
{
sourceId: "failing",
async feedSourceForUser() {
return failingSource
},
},
],
})
const manager = new UserSessionManager({ providers: [() => failingSource] })
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed")
@@ -171,29 +168,167 @@ describe("GET /api/feed", () => {
expect(body.errors[0]!.sourceId).toBe("failing")
expect(body.errors[0]!.error).toBe("connection timeout")
})
})
test("returns 503 when all providers fail", async () => {
const manager = new UserSessionManager({
providers: [
describe("GET /api/feed?render=json-render", () => {
const stubRenderer: FeedItemRenderer = (item) => ({
$$typeof: JRX_NODE,
type: "FeedCard",
props: {},
children: [
{
sourceId: "test",
async feedSourceForUser() {
throw new Error("provider down")
},
$$typeof: JRX_NODE,
type: "SansSerifText",
props: { content: `Rendered: ${item.data.value}` },
children: [],
key: undefined,
visible: undefined,
on: undefined,
repeat: undefined,
watch: undefined,
},
],
key: undefined,
visible: undefined,
on: undefined,
repeat: undefined,
watch: undefined,
})
const rendererProvider = {
feedRendererForUser: () => new FeedRenderer({ "test-source": stubRenderer }),
}
test("returns rendered items with ui field as Spec", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test-source",
type: "renderable",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: "hello" },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
rendererProvider,
})
const app = buildTestApp(manager, "user-1")
const spy = spyOn(console, "error").mockImplementation(() => {})
const res = await app.request("/api/feed?render=json-render")
expect(res.status).toBe(200)
const body = (await res.json()) as RenderedFeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("item-1")
expect(body.items[0]!.ui).toBeDefined()
expect(body.items[0]!.ui.root).toBeDefined()
expect(body.items[0]!.ui.elements).toBeDefined()
})
test("drops items without a renderer", async () => {
const items: FeedItem[] = [
{
id: "renderable-1",
sourceId: "test-source",
type: "renderable",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: "yes" },
},
{
id: "unrenderable-1",
sourceId: "other-source",
type: "no-renderer",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: "no" },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
rendererProvider,
})
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed?render=json-render")
expect(res.status).toBe(200)
const body = (await res.json()) as RenderedFeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!.id).toBe("renderable-1")
})
test("returns empty items when no renderers match", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "other-source",
type: "no-renderer",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
rendererProvider,
})
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed?render=json-render")
expect(res.status).toBe(200)
const body = (await res.json()) as RenderedFeedResponse
expect(body.items).toHaveLength(0)
})
test("returns 400 for unknown render format", async () => {
const manager = new UserSessionManager({
providers: [() => createStubSource("test")],
rendererProvider,
})
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed?render=unknown")
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("unknown")
})
test("returns 500 when renderer is not available", async () => {
const manager = new UserSessionManager({
providers: [() => createStubSource("test")],
})
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed?render=json-render")
expect(res.status).toBe(500)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not available")
})
test("without render param returns raw items (no ui field)", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test-source",
type: "renderable",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { value: 42 },
},
]
const manager = new UserSessionManager({
providers: [() => createStubSource("test", items)],
rendererProvider,
})
const app = buildTestApp(manager, "user-1")
const res = await app.request("/api/feed")
expect(res.status).toBe(503)
const body = (await res.json()) as { error: string }
expect(body.error).toBe("Service unavailable")
spy.mockRestore()
expect(res.status).toBe(200)
const body = (await res.json()) as FeedResponse
expect(body.items).toHaveLength(1)
expect(body.items[0]!).not.toHaveProperty("ui")
})
})
@@ -205,19 +340,12 @@ describe("GET /api/context", () => {
// The mock auth middleware always injects this hardcoded user ID
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
async function buildContextApp(userId?: string) {
function buildContextApp(userId?: string) {
const manager = new UserSessionManager({
providers: [
{
sourceId: "weather",
async feedSourceForUser() {
return createStubSource("weather", [], contextEntries)
},
},
],
providers: [() => createStubSource("weather", [], contextEntries)],
})
const app = buildTestApp(manager, userId)
const session = await manager.getOrCreate(mockUserId)
const session = manager.getOrCreate(mockUserId)
return { app, session }
}
@@ -231,7 +359,7 @@ describe("GET /api/context", () => {
})
test("returns 400 when key param is missing", async () => {
const { app } = await buildContextApp("user-1")
const { app } = buildContextApp("user-1")
const res = await app.request("/api/context")
@@ -241,7 +369,7 @@ describe("GET /api/context", () => {
})
test("returns 400 when key is invalid JSON", async () => {
const { app } = await buildContextApp("user-1")
const { app } = buildContextApp("user-1")
const res = await app.request("/api/context?key=notjson")
@@ -251,7 +379,7 @@ describe("GET /api/context", () => {
})
test("returns 400 when key is not an array", async () => {
const { app } = await buildContextApp("user-1")
const { app } = buildContextApp("user-1")
const res = await app.request('/api/context?key="string"')
@@ -261,7 +389,7 @@ describe("GET /api/context", () => {
})
test("returns 400 when key contains invalid element types", async () => {
const { app } = await buildContextApp("user-1")
const { app } = buildContextApp("user-1")
const res = await app.request("/api/context?key=[true,null,[1,2]]")
@@ -271,7 +399,7 @@ describe("GET /api/context", () => {
})
test("returns 400 when key is an empty array", async () => {
const { app } = await buildContextApp("user-1")
const { app } = buildContextApp("user-1")
const res = await app.request("/api/context?key=[]")
@@ -281,7 +409,7 @@ describe("GET /api/context", () => {
})
test("returns 400 when match param is invalid", async () => {
const { app } = await buildContextApp("user-1")
const { app } = buildContextApp("user-1")
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
@@ -291,7 +419,7 @@ describe("GET /api/context", () => {
})
test("returns exact match with match=exact", async () => {
const { app, session } = await buildContextApp("user-1")
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
@@ -303,7 +431,7 @@ describe("GET /api/context", () => {
})
test("returns 404 with match=exact when only prefix would match", async () => {
const { app, session } = await buildContextApp("user-1")
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
@@ -312,7 +440,7 @@ describe("GET /api/context", () => {
})
test("returns prefix match with match=prefix", async () => {
const { app, session } = await buildContextApp("user-1")
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
@@ -329,7 +457,7 @@ describe("GET /api/context", () => {
})
test("default mode returns exact match when available", async () => {
const { app, session } = await buildContextApp("user-1")
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
@@ -341,7 +469,7 @@ describe("GET /api/context", () => {
})
test("default mode falls back to prefix when no exact match", async () => {
const { app, session } = await buildContextApp("user-1")
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["aelis.weather"]')
@@ -357,7 +485,7 @@ describe("GET /api/context", () => {
})
test("returns 404 when neither exact nor prefix matches", async () => {
const { app, session } = await buildContextApp("user-1")
const { app, session } = buildContextApp("user-1")
await session.engine.refresh()
const res = await app.request('/api/context?key=["nonexistent"]')

View File

@@ -1,6 +1,7 @@
import type { Context, Hono } from "hono"
import { contextKey } from "@aelis/core"
import { render } from "@nym.sh/jrx"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
@@ -33,17 +34,35 @@ export function registerFeedHttpHandlers(
async function handleGetFeed(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
let session
try {
session = await sessionManager.getOrCreate(user.id)
} catch (err) {
console.error("[handleGetFeed] Failed to create session:", err)
return c.json({ error: "Service unavailable" }, 503)
}
const session = sessionManager.getOrCreate(user.id)
const feed = await session.feed()
const renderParam = c.req.query("render")
if (renderParam !== undefined) {
if (renderParam !== "json-render") {
return c.json({ error: `Unknown render format: "${renderParam}"` }, 400)
}
if (!session.renderer) {
return c.json({ error: "Rendering is not available" }, 500)
}
const renderedItems = session.renderer.render(feed.items).map((item) => ({
...item,
ui: render(item.ui),
}))
return c.json({
items: renderedItems,
errors: feed.errors.map((e) => ({
sourceId: e.sourceId,
error: e.error.message,
})),
})
}
return c.json({
items: feed.items,
errors: feed.errors.map((e) => ({
@@ -53,7 +72,7 @@ async function handleGetFeed(c: Context<Env>) {
})
}
async function handleGetContext(c: Context<Env>) {
function handleGetContext(c: Context<Env>) {
const keyParam = c.req.query("key")
if (!keyParam) {
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
@@ -77,15 +96,7 @@ async function handleGetContext(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
let session
try {
session = await sessionManager.getOrCreate(user.id)
} catch (err) {
console.error("[handleGetContext] Failed to create session:", err)
return c.json({ error: "Service unavailable" }, 503)
}
const session = sessionManager.getOrCreate(user.id)
const context = session.engine.currentContext()
const key = contextKey(...parsed)

View File

@@ -1,62 +0,0 @@
import { randomBytes } from "node:crypto"
import { describe, expect, test } from "bun:test"
import { CredentialEncryptor } from "./crypto.ts"
const TEST_KEY = randomBytes(32).toString("base64")
describe("CredentialEncryptor", () => {
const encryptor = new CredentialEncryptor(TEST_KEY)
test("round-trip with simple string", () => {
const plaintext = "hello world"
const encrypted = encryptor.encrypt(plaintext)
expect(encryptor.decrypt(encrypted)).toBe(plaintext)
})
test("round-trip with JSON credentials", () => {
const credentials = JSON.stringify({
accessToken: "ya29.a0AfH6SMB...",
refreshToken: "1//0dx...",
expiresAt: "2025-12-01T00:00:00Z",
})
const encrypted = encryptor.encrypt(credentials)
expect(encryptor.decrypt(encrypted)).toBe(credentials)
})
test("round-trip with empty string", () => {
const encrypted = encryptor.encrypt("")
expect(encryptor.decrypt(encrypted)).toBe("")
})
test("round-trip with unicode", () => {
const plaintext = "日本語テスト 🔐"
const encrypted = encryptor.encrypt(plaintext)
expect(encryptor.decrypt(encrypted)).toBe(plaintext)
})
test("each encryption produces different ciphertext (unique IV)", () => {
const plaintext = "same input"
const a = encryptor.encrypt(plaintext)
const b = encryptor.encrypt(plaintext)
expect(a).not.toEqual(b)
expect(encryptor.decrypt(a)).toBe(plaintext)
expect(encryptor.decrypt(b)).toBe(plaintext)
})
test("tampered ciphertext throws", () => {
const encrypted = encryptor.encrypt("secret")
encrypted[13]! ^= 0xff
expect(() => encryptor.decrypt(encrypted)).toThrow()
})
test("truncated data throws", () => {
expect(() => encryptor.decrypt(Buffer.alloc(10))).toThrow("Encrypted data is too short")
})
test("throws when key is wrong length", () => {
expect(() => new CredentialEncryptor(Buffer.from("too-short").toString("base64"))).toThrow(
"must be 32 bytes",
)
})
})

View File

@@ -1,60 +0,0 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
const ALGORITHM = "aes-256-gcm"
const IV_LENGTH = 12
const AUTH_TAG_LENGTH = 16
/**
* AES-256-GCM encryption for credential storage.
*
* Caches the parsed key on construction to avoid repeated
* env reads and Buffer allocations.
*/
export class CredentialEncryptor {
private readonly key: Buffer
constructor(base64Key: string) {
const key = Buffer.from(base64Key, "base64")
if (key.length !== 32) {
throw new Error(
`Encryption key must be 32 bytes (got ${key.length}). Generate with: openssl rand -base64 32`,
)
}
this.key = key
}
/**
* Encrypts plaintext using AES-256-GCM.
*
* Output format: [12-byte IV][ciphertext][16-byte auth tag]
*/
encrypt(plaintext: string): Buffer {
const iv = randomBytes(IV_LENGTH)
const cipher = createCipheriv(ALGORITHM, this.key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()])
const authTag = cipher.getAuthTag()
return Buffer.concat([iv, encrypted, authTag])
}
/**
* Decrypts a buffer produced by `encrypt`.
*
* Expects format: [12-byte IV][ciphertext][16-byte auth tag]
*/
decrypt(data: Buffer): string {
if (data.length < IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error("Encrypted data is too short")
}
const iv = data.subarray(0, IV_LENGTH)
const authTag = data.subarray(data.length - AUTH_TAG_LENGTH)
const ciphertext = data.subarray(IV_LENGTH, data.length - AUTH_TAG_LENGTH)
const decipher = createDecipheriv(ALGORITHM, this.key, iv)
decipher.setAuthTag(authTag)
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
}
}

View File

@@ -3,9 +3,10 @@ import type { Context, Hono } from "hono"
import { type } from "arktype"
import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { UserSessionManager } from "../session/index.ts"
import { requireSession } from "../auth/session-middleware.ts"
type Env = { Variables: { sessionManager: UserSessionManager } }
const locationInput = type({
@@ -15,21 +16,16 @@ const locationInput = type({
timestamp: "string.date.iso",
})
interface LocationHttpHandlersDeps {
sessionManager: UserSessionManager
authSessionMiddleware: AuthSessionMiddleware
}
export function registerLocationHttpHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware }: LocationHttpHandlersDeps,
{ sessionManager }: { sessionManager: UserSessionManager },
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
await next()
})
app.post("/api/location", inject, authSessionMiddleware, handleUpdateLocation)
app.post("/api/location", inject, requireSession, handleUpdateLocation)
}
async function handleUpdateLocation(c: Context<Env>) {
@@ -48,15 +44,7 @@ async function handleUpdateLocation(c: Context<Env>) {
const user = c.get("user")!
const sessionManager = c.get("sessionManager")
let session
try {
session = await sessionManager.getOrCreate(user.id)
} catch (err) {
console.error("[handleUpdateLocation] Failed to create session:", err)
return c.json({ error: "Service unavailable" }, 503)
}
const session = sessionManager.getOrCreate(user.id)
await session.engine.executeAction("aelis.location", "update-location", {
lat: result.lat,
lng: result.lng,

View File

@@ -1,26 +0,0 @@
import { LocationSource } from "@aelis/source-location"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
import { SourceDisabledError } from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts"
export class LocationSourceProvider implements FeedSourceProvider {
readonly sourceId = "aelis.location"
private readonly db: Database
constructor(db: Database) {
this.db = db
}
async feedSourceForUser(userId: string): Promise<LocationSource> {
const row = await sources(this.db, userId).find("aelis.location")
if (!row || !row.enabled) {
throw new SourceDisabledError("aelis.location", userId)
}
return new LocationSource()
}
}

View File

@@ -1,63 +0,0 @@
/**
* Creates an admin user account via Better Auth's server-side API.
*
* Usage:
* bun run src/scripts/create-admin.ts --name "Admin" --email admin@example.com --password secret123
*
* Requires DATABASE_URL and BETTER_AUTH_SECRET to be set (reads .env automatically).
*/
import { parseArgs } from "util"
import { createAuth } from "../auth/index.ts"
import { createDatabase } from "../db/index.ts"
function parseCliArgs(): { name: string; email: string; password: string } {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
name: { type: "string" },
email: { type: "string" },
password: { type: "string" },
},
strict: true,
})
if (!values.name || !values.email || !values.password) {
console.error(
"Usage: bun run src/scripts/create-admin.ts --name <name> --email <email> --password <password>",
)
process.exit(1)
}
return { name: values.name, email: values.email, password: values.password }
}
async function main() {
const { name, email, password } = parseCliArgs()
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error("DATABASE_URL is not set")
process.exit(1)
}
const { db, close } = createDatabase(databaseUrl)
try {
const auth = createAuth(db)
const result = await auth.api.createUser({
body: { name, email, password, role: "admin" },
})
console.log(`Admin account created: ${result.user.id} (${result.user.email})`)
} finally {
await close()
}
}
main().catch((err) => {
console.error("Failed to create admin account:", err)
process.exit(1)
})

View File

@@ -1,21 +1,18 @@
import { LocationSource } from "@aelis/source-location"
import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts"
import { createAuth } from "./auth/index.ts"
import { createRequireSession } from "./auth/session-middleware.ts"
import { createDatabase } from "./db/index.ts"
import { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.ts"
import { CALDAV_SOURCE_ID, calDavRenderer } from "./caldav/renderer-provider.ts"
import { registerFeedHttpHandlers } from "./engine/http.ts"
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
import { createLlmClient } from "./enhancement/llm-client.ts"
import { registerLocationHttpHandlers } from "./location/http.ts"
import { LocationSourceProvider } from "./location/provider.ts"
import { UserSessionManager } from "./session/index.ts"
import { FeedRenderer, UserSessionManager } from "./session/index.ts"
import { TFL_SOURCE_ID, tflRenderer } from "./tfl/renderer-provider.ts"
import { WeatherSourceProvider } from "./weather/provider.ts"
function main() {
const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!)
const auth = createAuth(db)
const openrouterApiKey = process.env.OPENROUTER_API_KEY
const feedEnhancer = openrouterApiKey
? createFeedEnhancer({
@@ -29,11 +26,15 @@ function main() {
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
}
const allRenderers = {
[TFL_SOURCE_ID]: tflRenderer,
[CALDAV_SOURCE_ID]: calDavRenderer,
}
const sessionManager = new UserSessionManager({
providers: [
new LocationSourceProvider(db),
() => new LocationSource(),
new WeatherSourceProvider({
db,
credentials: {
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
keyId: process.env.WEATHERKIT_KEY_ID!,
@@ -42,6 +43,9 @@ function main() {
},
}),
],
rendererProvider: {
feedRendererForUser: (_userId) => new FeedRenderer(allRenderers),
},
feedEnhancer,
})
@@ -49,20 +53,18 @@ function main() {
app.get("/health", (c) => c.json({ status: "ok" }))
const authSessionMiddleware = createRequireSession(auth)
const isDev = process.env.NODE_ENV !== "production"
const authSessionMiddleware = isDev ? mockAuthSessionMiddleware("dev-user") : requireSession
registerAuthHandlers(app, auth)
if (!isDev) {
registerAuthHandlers(app)
}
registerFeedHttpHandlers(app, {
sessionManager,
authSessionMiddleware,
})
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
process.on("SIGTERM", async () => {
await closeDb()
process.exit(0)
})
registerLocationHttpHandlers(app, { sessionManager })
return app
}

View File

@@ -0,0 +1,30 @@
import type { FeedItem, FeedItemRenderer, RenderedFeedItem } from "@aelis/core"
/**
* Renders feed items using registered renderers.
*
* Constructed with a map of source ID to renderer function.
* Items whose source has no renderer are silently dropped.
*/
export class FeedRenderer {
private readonly renderers: Map<string, FeedItemRenderer>
constructor(renderers: Record<string, FeedItemRenderer>) {
this.renderers = new Map(Object.entries(renderers))
}
/**
* Renders an array of feed items. Items whose sourceId has no
* registered renderer are silently dropped from the result.
*/
render(items: FeedItem[]): RenderedFeedItem[] {
const result: RenderedFeedItem[] = []
for (const item of items) {
const renderer = this.renderers.get(item.sourceId)
if (renderer) {
result.push({ ...item, ui: renderer(item) })
}
}
return result
}
}

View File

@@ -1,7 +1,9 @@
import type { FeedSource } from "@aelis/core"
export interface FeedSourceProvider {
/** The source ID this provider is responsible for (e.g., "aelis.location"). */
readonly sourceId: string
feedSourceForUser(userId: string): Promise<FeedSource>
feedSourceForUser(userId: string): FeedSource
}
export type FeedSourceProviderFn = (userId: string) => FeedSource
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn

View File

@@ -1,3 +1,9 @@
export type { FeedSourceProvider } from "./feed-source-provider.ts"
export type {
FeedSourceProvider,
FeedSourceProviderFn,
FeedSourceProviderInput,
} from "./feed-source-provider.ts"
export { FeedRenderer } from "./feed-renderer.ts"
export type { FeedRendererProvider } from "./renderer-provider.ts"
export { UserSession } from "./user-session.ts"
export { UserSessionManager } from "./user-session-manager.ts"

View File

@@ -0,0 +1,5 @@
import type { FeedRenderer } from "./feed-renderer.ts"
export interface FeedRendererProvider {
feedRendererForUser(userId: string): FeedRenderer
}

View File

@@ -1,85 +1,48 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
import { LocationSource } from "@aelis/source-location"
import { WeatherSource } from "@aelis/source-weatherkit"
import { describe, expect, mock, spyOn, test } from "bun:test"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { describe, expect, mock, test } from "bun:test"
import { WeatherSourceProvider } from "../weather/provider.ts"
import { UserSessionManager } from "./user-session-manager.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {
id,
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems() {
return items
},
}
}
function createStubProvider(
sourceId: string,
factory: (userId: string) => Promise<FeedSource> = async () => createStubSource(sourceId),
): FeedSourceProvider {
return { sourceId, feedSourceForUser: factory }
}
const locationProvider: FeedSourceProvider = {
sourceId: "aelis.location",
async feedSourceForUser() {
return new LocationSource()
},
}
const weatherProvider: FeedSourceProvider = {
sourceId: "aelis.weather",
async feedSourceForUser() {
return new WeatherSource({ client: { fetch: async () => ({}) as never } })
},
const mockWeatherClient: WeatherKitClient = {
fetch: async () => ({}) as WeatherKitResponse,
}
describe("UserSessionManager", () => {
test("getOrCreate creates session on first call", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
test("getOrCreate creates session on first call", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
expect(session).toBeDefined()
expect(session.engine).toBeDefined()
})
test("getOrCreate returns same session for same user", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
test("getOrCreate returns same session for same user", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-1")
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-1")
expect(session1).toBe(session2)
})
test("getOrCreate returns different sessions for different users", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
test("getOrCreate returns different sessions for different users", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-2")
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
expect(session1).not.toBe(session2)
})
test("each user gets independent source instances", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
test("each user gets independent source instances", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-2")
const session1 = manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-2")
const source1 = session1.getSource<LocationSource>("aelis.location")
const source2 = session2.getSource<LocationSource>("aelis.location")
@@ -87,37 +50,58 @@ describe("UserSessionManager", () => {
expect(source1).not.toBe(source2)
})
test("remove destroys session and allows re-creation", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
test("remove destroys session and allows re-creation", () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session1 = await manager.getOrCreate("user-1")
const session1 = manager.getOrCreate("user-1")
manager.remove("user-1")
const session2 = await manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-1")
expect(session1).not.toBe(session2)
})
test("remove is no-op for unknown user", () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
expect(() => manager.remove("unknown")).not.toThrow()
})
test("registers multiple providers", async () => {
const manager = new UserSessionManager({
providers: [locationProvider, weatherProvider],
test("accepts function providers", async () => {
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result.errors).toHaveLength(0)
})
const session = await manager.getOrCreate("user-1")
test("accepts object providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({
providers: [() => new LocationSource(), provider],
})
const session = manager.getOrCreate("user-1")
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("accepts mixed providers", () => {
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
const manager = new UserSessionManager({
providers: [() => new LocationSource(), provider],
})
const session = manager.getOrCreate("user-1")
expect(session.getSource("aelis.location")).toBeDefined()
expect(session.getSource("aelis.weather")).toBeDefined()
})
test("refresh returns feed result through session", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
const result = await session.engine.refresh()
expect(result).toHaveProperty("context")
@@ -127,9 +111,9 @@ describe("UserSessionManager", () => {
})
test("location update via executeAction works", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
@@ -142,10 +126,10 @@ describe("UserSessionManager", () => {
})
test("subscribe receives updates after location push", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = await manager.getOrCreate("user-1")
const session = manager.getOrCreate("user-1")
session.engine.subscribe(callback)
await session.engine.executeAction("aelis.location", "update-location", {
@@ -162,16 +146,16 @@ describe("UserSessionManager", () => {
})
test("remove stops reactive updates", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
const callback = mock()
const session = await manager.getOrCreate("user-1")
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 = await manager.getOrCreate("user-1")
const session2 = manager.getOrCreate("user-1")
await session2.engine.executeAction("aelis.location", "update-location", {
lat: 51.5074,
lng: -0.1278,
@@ -183,308 +167,4 @@ describe("UserSessionManager", () => {
expect(callback).not.toHaveBeenCalled()
})
test("creates session with successful providers when some fail", async () => {
const failingProvider: FeedSourceProvider = {
sourceId: "aelis.failing",
async feedSourceForUser() {
throw new Error("provider failed")
},
}
const manager = new UserSessionManager({
providers: [locationProvider, failingProvider],
})
const spy = spyOn(console, "error").mockImplementation(() => {})
const session = await manager.getOrCreate("user-1")
expect(session).toBeDefined()
expect(session.getSource("aelis.location")).toBeDefined()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
test("throws AggregateError when all providers fail", async () => {
const manager = new UserSessionManager({
providers: [
{
sourceId: "aelis.fail-1",
async feedSourceForUser() {
throw new Error("first failed")
},
},
{
sourceId: "aelis.fail-2",
async feedSourceForUser() {
throw new Error("second failed")
},
},
],
})
await expect(manager.getOrCreate("user-1")).rejects.toBeInstanceOf(AggregateError)
})
test("concurrent getOrCreate for same user returns same session", async () => {
let callCount = 0
const manager = new UserSessionManager({
providers: [
{
sourceId: "aelis.location",
async feedSourceForUser() {
callCount++
await new Promise((resolve) => setTimeout(resolve, 10))
return new LocationSource()
},
},
],
})
const [session1, session2] = await Promise.all([
manager.getOrCreate("user-1"),
manager.getOrCreate("user-1"),
])
expect(session1).toBe(session2)
expect(callCount).toBe(1)
})
test("remove during in-flight getOrCreate prevents session from being stored", async () => {
let resolveProvider: () => void
const providerGate = new Promise<void>((r) => {
resolveProvider = r
})
const manager = new UserSessionManager({
providers: [
{
sourceId: "aelis.location",
async feedSourceForUser() {
await providerGate
return new LocationSource()
},
},
],
})
const sessionPromise = manager.getOrCreate("user-1")
// remove() while provider is still resolving
manager.remove("user-1")
// Let the provider finish
resolveProvider!()
await expect(sessionPromise).rejects.toThrow("removed during creation")
// A fresh getOrCreate should produce a new session, not the cancelled one
const freshSession = await manager.getOrCreate("user-1")
expect(freshSession).toBeDefined()
expect(freshSession.engine).toBeDefined()
})
})
describe("UserSessionManager.replaceProvider", () => {
test("replaces source in all active sessions", async () => {
const itemsV1: FeedItem[] = [
{
id: "v1",
sourceId: "test",
type: "test",
timestamp: new Date(),
data: { version: 1 },
},
]
const itemsV2: FeedItem[] = [
{
id: "v2",
sourceId: "test",
type: "test",
timestamp: new Date(),
data: { version: 2 },
},
]
const providerV1 = createStubProvider("test", async () => createStubSource("test", itemsV1))
const manager = new UserSessionManager({ providers: [providerV1] })
const session1 = await manager.getOrCreate("user-1")
const session2 = await manager.getOrCreate("user-2")
// Verify v1 items
const feed1 = await session1.feed()
expect(feed1.items[0]!.data.version).toBe(1)
// Replace provider
const providerV2 = createStubProvider("test", async () => createStubSource("test", itemsV2))
await manager.replaceProvider(providerV2)
// Both sessions should now serve v2 items
const feed1After = await session1.feed()
const feed2After = await session2.feed()
expect(feed1After.items[0]!.data.version).toBe(2)
expect(feed2After.items[0]!.data.version).toBe(2)
})
test("throws for unknown provider sourceId", async () => {
const manager = new UserSessionManager({ providers: [locationProvider] })
const unknownProvider = createStubProvider("aelis.unknown")
await expect(manager.replaceProvider(unknownProvider)).rejects.toThrow(
"no existing provider with that sourceId",
)
})
test("removes source from session when new provider fails for a user", async () => {
const providerV1 = createStubProvider("test", async () => createStubSource("test"))
const manager = new UserSessionManager({ providers: [providerV1] })
const session = await manager.getOrCreate("user-1")
expect(session.getSource("test")).toBeDefined()
const spy = spyOn(console, "error").mockImplementation(() => {})
const failingProvider = createStubProvider("test", async () => {
throw new Error("source disabled")
})
await manager.replaceProvider(failingProvider)
expect(session.getSource("test")).toBeUndefined()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
test("new sessions use the replaced provider", async () => {
const itemsV1: FeedItem[] = [
{
id: "v1",
sourceId: "test",
type: "test",
timestamp: new Date(),
data: { version: 1 },
},
]
const itemsV2: FeedItem[] = [
{
id: "v2",
sourceId: "test",
type: "test",
timestamp: new Date(),
data: { version: 2 },
},
]
const providerV1 = createStubProvider("test", async () => createStubSource("test", itemsV1))
const manager = new UserSessionManager({ providers: [providerV1] })
const providerV2 = createStubProvider("test", async () => createStubSource("test", itemsV2))
await manager.replaceProvider(providerV2)
// New session should use v2
const session = await manager.getOrCreate("user-new")
const feed = await session.feed()
expect(feed.items[0]!.data.version).toBe(2)
})
test("does not affect other providers' sources", async () => {
const providerA = createStubProvider("source-a", async () =>
createStubSource("source-a", [
{
id: "a-1",
sourceId: "source-a",
type: "test",
timestamp: new Date(),
data: { from: "a" },
},
]),
)
const providerB = createStubProvider("source-b", async () =>
createStubSource("source-b", [
{
id: "b-1",
sourceId: "source-b",
type: "test",
timestamp: new Date(),
data: { from: "b" },
},
]),
)
const manager = new UserSessionManager({ providers: [providerA, providerB] })
const session = await manager.getOrCreate("user-1")
// Replace only source-a
const providerA2 = createStubProvider("source-a", async () =>
createStubSource("source-a", [
{
id: "a-2",
sourceId: "source-a",
type: "test",
timestamp: new Date(),
data: { from: "a-new" },
},
]),
)
await manager.replaceProvider(providerA2)
// source-b should be unaffected
expect(session.getSource("source-b")).toBeDefined()
const feed = await session.feed()
const ids = feed.items.map((i) => i.id).sort()
expect(ids).toEqual(["a-2", "b-1"])
})
test("updates sessions that are still being created", async () => {
const itemsV1: FeedItem[] = [
{
id: "v1",
sourceId: "test",
type: "test",
timestamp: new Date(),
data: { version: 1 },
},
]
const itemsV2: FeedItem[] = [
{
id: "v2",
sourceId: "test",
type: "test",
timestamp: new Date(),
data: { version: 2 },
},
]
let resolveCreation: () => void
const creationGate = new Promise<void>((r) => {
resolveCreation = r
})
const providerV1 = createStubProvider("test", async () => {
await creationGate
return createStubSource("test", itemsV1)
})
const manager = new UserSessionManager({ providers: [providerV1] })
// Start session creation but don't let it finish yet
const sessionPromise = manager.getOrCreate("user-1")
// Replace provider while session is still pending
const providerV2 = createStubProvider("test", async () => createStubSource("test", itemsV2))
const replacePromise = manager.replaceProvider(providerV2)
// Let the original creation finish
resolveCreation!()
const session = await sessionPromise
await replacePromise
// Session should have been updated to v2
const feed = await session.feed()
expect(feed.items[0]!.data.version).toBe(2)
})
})

View File

@@ -1,139 +1,48 @@
import type { FeedSource } from "@aelis/core"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
import type { FeedRendererProvider } from "./renderer-provider.ts"
import { UserSession } from "./user-session.ts"
export interface UserSessionManagerConfig {
providers: FeedSourceProvider[]
providers: FeedSourceProviderInput[]
rendererProvider?: FeedRendererProvider | null
feedEnhancer?: FeedEnhancer | null
}
export class UserSessionManager {
private sessions = new Map<string, { userId: string; session: UserSession }>()
private pending = new Map<string, Promise<UserSession>>()
private readonly providers = new Map<string, FeedSourceProvider>()
private sessions = new Map<string, UserSession>()
private readonly providers: FeedSourceProviderInput[]
private readonly rendererProvider: FeedRendererProvider | null
private readonly feedEnhancer: FeedEnhancer | null
constructor(config: UserSessionManagerConfig) {
for (const provider of config.providers) {
this.providers.set(provider.sourceId, provider)
}
this.providers = config.providers
this.rendererProvider = config.rendererProvider ?? null
this.feedEnhancer = config.feedEnhancer ?? null
}
async getOrCreate(userId: string): Promise<UserSession> {
const existing = this.sessions.get(userId)
if (existing) return existing.session
const inflight = this.pending.get(userId)
if (inflight) return inflight
const promise = this.createSession(userId)
this.pending.set(userId, promise)
try {
const session = await promise
// If remove() was called while we were awaiting, it clears the
// pending entry. Detect that and destroy the session immediately.
if (!this.pending.has(userId)) {
session.destroy()
throw new Error(`Session for user ${userId} was removed during creation`)
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,
enhancer: this.feedEnhancer,
renderer: this.rendererProvider?.feedRendererForUser(userId) ?? null,
})
this.sessions.set(userId, session)
}
this.sessions.set(userId, { userId, session })
return session
} finally {
this.pending.delete(userId)
}
}
remove(userId: string): void {
const entry = this.sessions.get(userId)
if (entry) {
entry.session.destroy()
const session = this.sessions.get(userId)
if (session) {
session.destroy()
this.sessions.delete(userId)
}
// Cancel any in-flight creation so getOrCreate won't store the session
this.pending.delete(userId)
}
/**
* Replaces a provider and updates all active sessions.
* The new provider must have the same sourceId as an existing one.
* For each active session, resolves a new source from the provider.
* If the provider fails for a user, the old source is removed from that session.
*/
async replaceProvider(provider: FeedSourceProvider): Promise<void> {
if (!this.providers.has(provider.sourceId)) {
throw new Error(
`Cannot replace provider "${provider.sourceId}": no existing provider with that sourceId`,
)
}
this.providers.set(provider.sourceId, provider)
const updates: Promise<void>[] = []
for (const [, { userId, session }] of this.sessions) {
updates.push(this.updateSessionSource(provider, userId, session))
}
// Also update sessions that are currently being created so they
// don't land in this.sessions with a stale source.
for (const [userId, pendingPromise] of this.pending) {
updates.push(
pendingPromise
.then((session) => this.updateSessionSource(provider, userId, session))
.catch(() => {
// Session creation itself failed — nothing to update.
}),
)
}
await Promise.all(updates)
}
private async updateSessionSource(
provider: FeedSourceProvider,
userId: string,
session: UserSession,
): Promise<void> {
try {
const newSource = await provider.feedSourceForUser(userId)
session.replaceSource(provider.sourceId, newSource)
} catch (err) {
console.error(
`[UserSessionManager] replaceProvider("${provider.sourceId}") failed for user ${userId}:`,
err,
)
session.removeSource(provider.sourceId)
}
}
private async createSession(userId: string): Promise<UserSession> {
const results = await Promise.allSettled(
Array.from(this.providers.values()).map((p) => p.feedSourceForUser(userId)),
)
const sources: FeedSource[] = []
const errors: unknown[] = []
for (const result of results) {
if (result.status === "fulfilled") {
sources.push(result.value)
} else {
errors.push(result.reason)
}
}
if (sources.length === 0 && errors.length > 0) {
throw new AggregateError(errors, "All feed source providers failed")
}
for (const error of errors) {
console.error("[UserSessionManager] Feed source provider failed:", error)
}
return new UserSession(sources, this.feedEnhancer)
}
}

View File

@@ -1,8 +1,16 @@
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
import type {
ActionDefinition,
ContextEntry,
FeedItem,
FeedItemRenderer,
FeedSource,
} from "@aelis/core"
import { LocationSource } from "@aelis/source-location"
import { JRX_NODE } from "@nym.sh/jrx"
import { describe, expect, test } from "bun:test"
import { FeedRenderer } from "./feed-renderer.ts"
import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
@@ -25,7 +33,9 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
describe("UserSession", () => {
test("registers sources and starts engine", async () => {
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
const session = new UserSession({
sources: [createStubSource("test-a"), createStubSource("test-b")],
})
const result = await session.engine.refresh()
@@ -34,7 +44,7 @@ describe("UserSession", () => {
test("getSource returns registered source", () => {
const location = new LocationSource()
const session = new UserSession([location])
const session = new UserSession({ sources: [location] })
const result = session.getSource<LocationSource>("aelis.location")
@@ -42,13 +52,13 @@ describe("UserSession", () => {
})
test("getSource returns undefined for unknown source", () => {
const session = new UserSession([createStubSource("test")])
const session = new UserSession({ sources: [createStubSource("test")] })
expect(session.getSource("unknown")).toBeUndefined()
})
test("destroy stops engine and clears sources", () => {
const session = new UserSession([createStubSource("test")])
const session = new UserSession({ sources: [createStubSource("test")] })
session.destroy()
@@ -57,7 +67,7 @@ describe("UserSession", () => {
test("engine.executeAction routes to correct source", async () => {
const location = new LocationSource()
const session = new UserSession([location])
const session = new UserSession({ sources: [location] })
await session.engine.executeAction("aelis.location", "update-location", {
lat: 51.5,
@@ -82,7 +92,7 @@ describe("UserSession.feed", () => {
data: { value: 42 },
},
]
const session = new UserSession([createStubSource("test", items)])
const session = new UserSession({ sources: [createStubSource("test", items)] })
const result = await session.feed()
@@ -103,7 +113,7 @@ describe("UserSession.feed", () => {
const enhancer = async (feedItems: FeedItem[]) =>
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
const session = new UserSession([createStubSource("test", items)], enhancer)
const session = new UserSession({ sources: [createStubSource("test", items)], enhancer })
const result = await session.feed()
@@ -127,7 +137,7 @@ describe("UserSession.feed", () => {
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
}
const session = new UserSession([createStubSource("test", items)], enhancer)
const session = new UserSession({ sources: [createStubSource("test", items)], enhancer })
const result1 = await session.feed()
expect(result1.items[0]!.data.enhanced).toBe(true)
@@ -162,7 +172,7 @@ describe("UserSession.feed", () => {
}))
}
const session = new UserSession([source], enhancer)
const session = new UserSession({ sources: [source], enhancer })
// First feed triggers refresh + enhancement
const result1 = await session.feed()
@@ -205,7 +215,7 @@ describe("UserSession.feed", () => {
throw new Error("enhancement exploded")
}
const session = new UserSession([createStubSource("test", items)], enhancer)
const session = new UserSession({ sources: [createStubSource("test", items)], enhancer })
const result = await session.feed()
@@ -215,174 +225,80 @@ describe("UserSession.feed", () => {
})
})
describe("UserSession.replaceSource", () => {
test("replaces source and invalidates feed cache", async () => {
const itemsA: FeedItem[] = [
{
id: "a-1",
sourceId: "test",
type: "test",
describe("FeedRenderer", () => {
const stubRenderer: FeedItemRenderer = (item) => ({
$$typeof: JRX_NODE,
type: "FeedCard",
props: { content: item.data.value },
children: [],
key: undefined,
visible: undefined,
on: undefined,
repeat: undefined,
watch: undefined,
})
function makeItem(sourceId: string): FeedItem {
return {
id: `item-${sourceId}`,
sourceId,
type: "some-type",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { from: "a" },
},
]
const itemsB: FeedItem[] = [
{
id: "b-1",
sourceId: "test",
type: "test",
timestamp: new Date("2025-01-01T00:00:00.000Z"),
data: { from: "b" },
},
]
const sourceA = createStubSource("test", itemsA)
const session = new UserSession([sourceA])
const result1 = await session.feed()
expect(result1.items).toHaveLength(1)
expect(result1.items[0]!.data.from).toBe("a")
const sourceB = createStubSource("test", itemsB)
session.replaceSource("test", sourceB)
const result2 = await session.feed()
expect(result2.items).toHaveLength(1)
expect(result2.items[0]!.data.from).toBe("b")
})
test("getSource returns new source after replace", () => {
const sourceA = createStubSource("test")
const session = new UserSession([sourceA])
const sourceB = createStubSource("test")
session.replaceSource("test", sourceB)
expect(session.getSource("test")).toBe(sourceB)
expect(session.getSource("test")).not.toBe(sourceA)
})
test("throws when replacing a source that is not registered", () => {
const session = new UserSession([createStubSource("test")])
expect(() => session.replaceSource("nonexistent", createStubSource("other"))).toThrow(
'Cannot replace source "nonexistent": not registered',
)
})
test("other sources are unaffected by replace", async () => {
const sourceA = createStubSource("source-a", [
{
id: "a-1",
sourceId: "source-a",
type: "test",
timestamp: new Date(),
data: { from: "a" },
},
])
const sourceB = createStubSource("source-b", [
{
id: "b-1",
sourceId: "source-b",
type: "test",
timestamp: new Date(),
data: { from: "b" },
},
])
const session = new UserSession([sourceA, sourceB])
const replacement = createStubSource("source-a", [
{
id: "a-2",
sourceId: "source-a",
type: "test",
timestamp: new Date(),
data: { from: "a-new" },
},
])
session.replaceSource("source-a", replacement)
const result = await session.feed()
expect(result.items).toHaveLength(2)
const ids = result.items.map((i) => i.id).sort()
expect(ids).toEqual(["a-2", "b-1"])
})
test("invalidates enhancement cache on replace", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date(),
data: { version: 1 },
},
]
let enhanceCount = 0
const enhancer = async (feedItems: FeedItem[]) => {
enhanceCount++
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
data: { value: 42 },
}
}
const session = new UserSession([createStubSource("test", items)], enhancer)
test("renders items with matching sourceId", () => {
const renderer = new FeedRenderer({ "test-source": stubRenderer })
await session.feed()
expect(enhanceCount).toBe(1)
const result = renderer.render([makeItem("test-source")])
const newItems: FeedItem[] = [
{
id: "item-2",
sourceId: "test",
type: "test",
timestamp: new Date(),
data: { version: 2 },
},
]
session.replaceSource("test", createStubSource("test", newItems))
expect(result).toHaveLength(1)
expect(result[0]!.ui).toBeDefined()
expect(result[0]!.id).toBe("item-test-source")
})
const result = await session.feed()
expect(enhanceCount).toBe(2)
expect(result.items[0]!.id).toBe("item-2")
expect(result.items[0]!.data.enhanced).toBe(true)
test("drops items without a matching renderer", () => {
const renderer = new FeedRenderer({ "test-source": stubRenderer })
const result = renderer.render([makeItem("unknown-source")])
expect(result).toHaveLength(0)
})
test("filters mixed items", () => {
const renderer = new FeedRenderer({ "test-source": stubRenderer })
const result = renderer.render([makeItem("test-source"), makeItem("unknown-source")])
expect(result).toHaveLength(1)
expect(result[0]!.id).toBe("item-test-source")
})
test("renders empty array for empty input", () => {
const renderer = new FeedRenderer({ "test-source": stubRenderer })
expect(renderer.render([])).toHaveLength(0)
})
test("renders with no renderers registered", () => {
const renderer = new FeedRenderer({})
expect(renderer.render([makeItem("test-source")])).toHaveLength(0)
})
})
describe("UserSession.removeSource", () => {
test("removes source from engine and sources map", () => {
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
describe("UserSession.renderer", () => {
test("exposes renderer when provided", () => {
const renderer = new FeedRenderer({})
const session = new UserSession({ sources: [createStubSource("test")], renderer })
session.removeSource("test-a")
expect(session.getSource("test-a")).toBeUndefined()
expect(session.getSource("test-b")).toBeDefined()
expect(session.renderer).toBe(renderer)
})
test("invalidates feed cache on remove", async () => {
const items: FeedItem[] = [
{
id: "item-1",
sourceId: "test",
type: "test",
timestamp: new Date(),
data: {},
},
]
const session = new UserSession([createStubSource("test", items)])
test("renderer is null when not provided", () => {
const session = new UserSession({ sources: [createStubSource("test")] })
const result1 = await session.feed()
expect(result1.items).toHaveLength(1)
session.removeSource("test")
const result2 = await session.feed()
expect(result2.items).toHaveLength(0)
})
test("is a no-op for unknown source", () => {
const session = new UserSession([createStubSource("test")])
expect(() => session.removeSource("unknown")).not.toThrow()
expect(session.getSource("test")).toBeDefined()
expect(session.renderer).toBeNull()
})
})

View File

@@ -1,9 +1,17 @@
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aelis/core"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { FeedRenderer } from "./feed-renderer.ts"
export interface UserSessionOptions {
sources: FeedSource[]
enhancer?: FeedEnhancer | null
renderer?: FeedRenderer | null
}
export class UserSession {
readonly engine: FeedEngine
readonly renderer: FeedRenderer | null
private sources = new Map<string, FeedSource>()
private readonly enhancer: FeedEnhancer | null
private enhancedItems: FeedItem[] | null = null
@@ -12,10 +20,11 @@ export class UserSession {
private enhancingPromise: Promise<void> | null = null
private unsubscribe: (() => void) | null = null
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
constructor(options: UserSessionOptions) {
this.engine = new FeedEngine()
this.enhancer = enhancer ?? null
for (const source of sources) {
this.enhancer = options.enhancer ?? null
this.renderer = options.renderer ?? null
for (const source of options.sources) {
this.sources.set(source.id, source)
this.engine.register(source)
}
@@ -67,59 +76,6 @@ export class UserSession {
return this.sources.get(sourceId) as T | undefined
}
/**
* Replaces a source in the engine and invalidates all caches.
* Stops and restarts the engine to re-establish reactive subscriptions.
*/
replaceSource(oldSourceId: string, newSource: FeedSource): void {
if (!this.sources.has(oldSourceId)) {
throw new Error(`Cannot replace source "${oldSourceId}": not registered`)
}
const wasStarted = this.engine.isStarted()
if (wasStarted) {
this.engine.stop()
}
this.engine.unregister(oldSourceId)
this.sources.delete(oldSourceId)
this.engine.register(newSource)
this.sources.set(newSource.id, newSource)
this.invalidateEnhancement()
this.enhancingPromise = null
if (wasStarted) {
this.engine.start()
}
}
/**
* Removes a source from the engine and invalidates all caches.
* Stops and restarts the engine to clean up reactive subscriptions.
*/
removeSource(sourceId: string): void {
if (!this.sources.has(sourceId)) return
const wasStarted = this.engine.isStarted()
if (wasStarted) {
this.engine.stop()
}
this.engine.unregister(sourceId)
this.sources.delete(sourceId)
this.invalidateEnhancement()
this.enhancingPromise = null
if (wasStarted) {
this.engine.start()
}
}
destroy(): void {
this.unsubscribe?.()
this.unsubscribe = null

View File

@@ -1,32 +0,0 @@
/**
* Thrown by a FeedSourceProvider when the source is not enabled for a user.
*
* UserSessionManager's Promise.allSettled handles this gracefully —
* the source is excluded from the session without crashing.
*/
export class SourceDisabledError extends Error {
readonly sourceId: string
readonly userId: string
constructor(sourceId: string, userId: string) {
super(`Source "${sourceId}" is not enabled for user "${userId}"`)
this.name = "SourceDisabledError"
this.sourceId = sourceId
this.userId = userId
}
}
/**
* Thrown when an operation targets a user source that doesn't exist.
*/
export class SourceNotFoundError extends Error {
readonly sourceId: string
readonly userId: string
constructor(sourceId: string, userId: string) {
super(`Source "${sourceId}" not found for user "${userId}"`)
this.name = "SourceNotFoundError"
this.sourceId = sourceId
this.userId = userId
}
}

View File

@@ -1,79 +0,0 @@
import { and, eq } from "drizzle-orm"
import type { Database } from "../db/index.ts"
import { userSources } from "../db/schema.ts"
import { SourceNotFoundError } from "./errors.ts"
export function sources(db: Database, userId: string) {
return {
/** Returns all enabled sources for the user. */
async enabled() {
return db
.select()
.from(userSources)
.where(and(eq(userSources.userId, userId), eq(userSources.enabled, true)))
},
/** Returns a specific source by ID, or undefined. */
async find(sourceId: string) {
const rows = await db
.select()
.from(userSources)
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
.limit(1)
return rows[0]
},
/** Enables a source for the user. Throws if the source row doesn't exist. */
async enableSource(sourceId: string) {
const rows = await db
.update(userSources)
.set({ enabled: true, updatedAt: new Date() })
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
.returning({ id: userSources.id })
if (rows.length === 0) {
throw new SourceNotFoundError(sourceId, userId)
}
},
/** Disables a source for the user. Throws if the source row doesn't exist. */
async disableSource(sourceId: string) {
const rows = await db
.update(userSources)
.set({ enabled: false, updatedAt: new Date() })
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
.returning({ id: userSources.id })
if (rows.length === 0) {
throw new SourceNotFoundError(sourceId, userId)
}
},
/** Creates or updates the config for a source. */
async upsertConfig(sourceId: string, config: Record<string, unknown>) {
await db
.insert(userSources)
.values({ userId, sourceId, config })
.onConflictDoUpdate({
target: [userSources.userId, userSources.sourceId],
set: { config, updatedAt: new Date() },
})
},
/** Updates the encrypted credentials for a source. Throws if the source row doesn't exist. */
async updateCredentials(sourceId: string, credentials: Buffer) {
const rows = await db
.update(userSources)
.set({ credentials, updatedAt: new Date() })
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
.returning({ id: userSources.id })
if (rows.length === 0) {
throw new SourceNotFoundError(sourceId, userId)
}
},
}
}

View File

@@ -1,48 +1,19 @@
import { TflSource, type ITflApi, type TflLineId } from "@aelis/source-tfl"
import { type } from "arktype"
import { TflSource, type ITflApi } from "@aelis/source-tfl"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
import { SourceDisabledError } from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts"
export type TflSourceProviderOptions =
| { db: Database; apiKey: string; client?: never }
| { db: Database; apiKey?: never; client: ITflApi }
const tflConfig = type({
"lines?": "string[]",
})
| { apiKey: string; client?: never }
| { apiKey?: never; client: ITflApi }
export class TflSourceProvider implements FeedSourceProvider {
readonly sourceId = "aelis.tfl"
private readonly db: Database
private readonly apiKey: string | undefined
private readonly client: ITflApi | undefined
private readonly options: TflSourceProviderOptions
constructor(options: TflSourceProviderOptions) {
this.db = options.db
this.apiKey = "apiKey" in options ? options.apiKey : undefined
this.client = "client" in options ? options.client : undefined
this.options = options
}
async feedSourceForUser(userId: string): Promise<TflSource> {
const row = await sources(this.db, userId).find("aelis.tfl")
if (!row || !row.enabled) {
throw new SourceDisabledError("aelis.tfl", userId)
}
const parsed = tflConfig(row.config ?? {})
if (parsed instanceof type.errors) {
throw new Error(`Invalid TFL config for user ${userId}: ${parsed.summary}`)
}
return new TflSource({
apiKey: this.apiKey,
client: this.client,
lines: parsed.lines as TflLineId[] | undefined,
})
feedSourceForUser(_userId: string): TflSource {
return new TflSource(this.options)
}
}

View File

@@ -0,0 +1,7 @@
import type { FeedItemRenderer } from "@aelis/core"
import { renderTflAlert } from "@aelis/source-tfl"
export const TFL_SOURCE_ID = "aelis.tfl"
export const tflRenderer: FeedItemRenderer = renderTflAlert as FeedItemRenderer

View File

@@ -1,54 +1,15 @@
import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
import { type } from "arktype"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
import { SourceDisabledError } from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts"
export interface WeatherSourceProviderOptions {
db: Database
credentials: WeatherSourceOptions["credentials"]
client?: WeatherSourceOptions["client"]
}
const weatherConfig = type({
"units?": "'metric' | 'imperial'",
"hourlyLimit?": "number",
"dailyLimit?": "number",
})
export class WeatherSourceProvider implements FeedSourceProvider {
readonly sourceId = "aelis.weather"
private readonly db: Database
private readonly credentials: WeatherSourceOptions["credentials"]
private readonly client: WeatherSourceOptions["client"]
private readonly options: WeatherSourceOptions
constructor(options: WeatherSourceProviderOptions) {
this.db = options.db
this.credentials = options.credentials
this.client = options.client
constructor(options: WeatherSourceOptions) {
this.options = options
}
async feedSourceForUser(userId: string): Promise<WeatherSource> {
const row = await sources(this.db, userId).find("aelis.weather")
if (!row || !row.enabled) {
throw new SourceDisabledError("aelis.weather", userId)
}
const parsed = weatherConfig(row.config ?? {})
if (parsed instanceof type.errors) {
throw new Error(`Invalid weather config for user ${userId}: ${parsed.summary}`)
}
return new WeatherSource({
credentials: this.credentials,
client: this.client,
units: parsed.units,
hourlyLimit: parsed.hourlyLimit,
dailyLimit: parsed.dailyLimit,
})
feedSourceForUser(_userId: string): WeatherSource {
return new WeatherSource(this.options)
}
}

View File

@@ -22,7 +22,6 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",

View File

@@ -1,6 +0,0 @@
import { ApiRequestMiddleware } from "./client"
export const authMiddleware: ApiRequestMiddleware = (_url, init) => {
// TODO: placeholder auth middleware
return init
}

View File

@@ -1,39 +0,0 @@
import { createContext, useContext } from "react"
export type ApiRequestMiddleware = (
url: Parameters<typeof fetch>[0],
init: RequestInit,
) => RequestInit
export class ApiClient {
private readonly baseUrl: string
private readonly middlewares: readonly ApiRequestMiddleware[]
static noop = new ApiClient({ baseUrl: "" })
constructor({
baseUrl,
middlewares = [],
}: {
baseUrl: string
middlewares?: ApiRequestMiddleware[]
}) {
this.baseUrl = baseUrl
this.middlewares = middlewares
}
async request<T>(...[url, init = {}]: Parameters<typeof fetch>): Promise<[Response, T]> {
const finalInit = this.middlewares.reduce(
(prevInit, middleware) => middleware(url, prevInit),
init,
)
return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then((res) =>
Promise.all([Promise.resolve(res), res.json()]),
)
}
}
export const ApiClientContext = createContext(ApiClient.noop)
export function useApiClient() {
return useContext(ApiClientContext)
}

View File

@@ -1,29 +1,17 @@
import "react-native-reanimated"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Stack } from "expo-router"
import { StatusBar } from "expo-status-bar"
import React from "react"
import { useColorScheme } from "react-native"
import tw, { useDeviceContext } from "twrnc"
import { authMiddleware } from "@/api/auth-middleware"
import { ApiClient, ApiClientContext } from "@/api/client"
const queryClient = new QueryClient()
const apiClient = new ApiClient({
baseUrl: process.env.EXPO_PUBLIC_API_BASE_URL ?? "",
middlewares: [authMiddleware],
})
export default function RootLayout() {
useDeviceContext(tw)
const colorScheme = useColorScheme()
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
return (
<ContextProvider>
<>
<Stack
screenOptions={{
headerShown: false,
@@ -52,14 +40,6 @@ export default function RootLayout() {
/>
</Stack>
<StatusBar style="auto" />
</ContextProvider>
)
}
function ContextProvider({ children }: React.PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>
<ApiClientContext value={apiClient}>{children}</ApiClientContext>
</QueryClientProvider>
</>
)
}

View File

@@ -1,13 +0,0 @@
import { queryOptions } from "@tanstack/react-query"
import { useApiClient } from "@/api/client"
import { FeedItem } from "./types"
export function useFeedQuery() {
const api = useApiClient()
return queryOptions({
queryKey: ["feed"],
queryFn: async () => api.request<{ items: FeedItem[] }>("/feed?render=json-render"),
})
}

View File

@@ -1,5 +0,0 @@
import { Spec } from "@json-render/core"
export interface FeedItem {
ui: Spec
}

174
bun.lock
View File

@@ -25,14 +25,15 @@
"@aelis/source-location": "workspace:*",
"@aelis/source-tfl": "workspace:*",
"@aelis/source-weatherkit": "workspace:*",
"@nym.sh/jrx": "^0.2.0",
"@openrouter/sdk": "^0.9.11",
"arktype": "^2.1.29",
"better-auth": "^1",
"drizzle-orm": "^0.45.1",
"hono": "^4",
"pg": "^8",
},
"devDependencies": {
"drizzle-kit": "^0.31.9",
"@types/pg": "^8",
},
},
"apps/aelis-client": {
@@ -46,7 +47,6 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.21",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
@@ -444,8 +444,6 @@
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="],
@@ -460,61 +458,57 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
@@ -1196,10 +1190,6 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
@@ -1706,8 +1696,6 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"dtrace-provider": ["dtrace-provider@0.8.8", "", { "dependencies": { "nan": "^2.14.0" } }, "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg=="],
@@ -1768,9 +1756,7 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -3322,8 +3308,6 @@
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -3812,8 +3796,6 @@
"twrnc/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=="],
"vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"waitlist-website/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
@@ -3836,50 +3818,6 @@
"@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -4146,58 +4084,6 @@
"twrnc/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=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"waitlist-website/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],

View File

@@ -1,230 +0,0 @@
# DB Persistence Layer Spec
## Problem Statement
AELIS currently hardcodes the same set of feed sources for every user. Source configuration (TFL lines, weather units, calendar IDs, etc.) and credentials (OAuth tokens) are not persisted. Users cannot customize which sources appear in their feed or configure source-specific settings.
The backend uses a raw `pg` Pool for Better Auth and has no ORM. We need a persistence layer that stores per-user source configuration and credentials, using Drizzle ORM with Bun.sql as the Postgres driver.
## Requirements
### 1. Replace `pg` with `Bun.sql`
- Remove `pg` and `@types/pg` dependencies
- Replace `db.ts` with a Drizzle instance backed by `Bun.sql` (`drizzle-orm/bun-sql`)
- All DB access goes through Drizzle — no raw Pool usage
### 2. Migrate Better Auth to Drizzle adapter
- Use `better-auth/adapters/drizzle` so auth tables are managed through the same Drizzle instance
- Define Better Auth tables (user, session, account, verification) in the Drizzle schema
- Better Auth's `database` option switches from `Pool` to the Drizzle adapter
### 3. User source configuration table
A `user_sources` table stores per-user source state:
| Column | Type | Description |
| ------------ | ------------------------ | ------------------------------------------------------------ |
| `id` | `uuid` PK | Row ID |
| `user_id` | `text` FK → `user.id` | Owner |
| `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) |
| `enabled` | `boolean` | Whether this source is active in the user's feed |
| `config` | `jsonb` | Source-specific configuration (validated by source at runtime)|
| `credentials`| `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) |
| `created_at` | `timestamp with tz` | Row creation time |
| `updated_at` | `timestamp with tz` | Last modification time |
- Unique constraint on `(user_id, source_id)` — one config row per source per user.
- `config` is a generic `jsonb` column. Each source package exports an arktype schema; the backend provider validates the JSON at source construction time.
- `credentials` is stored as encrypted bytes. Only OAuth tokens and secrets go here — non-sensitive config stays in `config`.
### 4. Credential encryption
- AES-256-GCM encryption for the `credentials` column
- Encryption key sourced from an environment variable (`CREDENTIALS_ENCRYPTION_KEY`)
- A `crypto` utility module in the backend provides `encrypt(plaintext)``Buffer` and `decrypt(ciphertext)``string`
- IV is generated per-encryption and stored as a prefix to the ciphertext
### 5. Default sources on signup
When a new user is created, seed `user_sources` rows for default sources:
| Source | Default config |
| ------------------ | --------------------------------------------------------------- |
| `aelis.location` | `{}` |
| `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` |
| `aelis.tfl` | `{ "lines": <all default lines> }` |
- Seeding happens via a Better Auth `after` hook on user creation, or via application-level logic after signup.
- Sources requiring credentials (Google Calendar, CalDAV) are **not** enabled by default — they require the user to connect an account first.
### 6. Source providers query DB
`FeedSourceProvider.feedSourceForUser` is already async (returns `Promise<FeedSource>`). `UserSessionManager.getOrCreate` is already async with in-flight deduplication and `Promise.allSettled`-based graceful degradation — if a provider throws, the source is skipped and the error is logged.
Each provider receives the Drizzle DB instance and queries `user_sources` internally. If the source is disabled or the row is missing, the provider throws a `SourceDisabledError`. If config validation fails, it throws with a descriptive message. Both cases are handled by `createSession`'s `Promise.allSettled` — the source is excluded from the session and the error is logged.
```typescript
class TflSourceProvider implements FeedSourceProvider {
constructor(private db: DrizzleDb, private apiKey: string) {}
async feedSourceForUser(userId: string): Promise<TflSource> {
const row = await this.db.select()
.from(userSources)
.where(and(
eq(userSources.userId, userId),
eq(userSources.sourceId, "aelis.tfl"),
eq(userSources.enabled, true),
))
.limit(1)
if (!row[0]) {
throw new SourceDisabledError("aelis.tfl", userId)
}
const config = tflSourceConfig(row[0].config ?? {})
if (config instanceof type.errors) {
throw new Error(`Invalid TFL config for user ${userId}: ${config.summary}`)
}
return new TflSource({ ...config, apiKey: this.apiKey })
}
}
```
No interface changes are needed — the existing async `FeedSourceProvider` and `UserSessionManager` signatures are sufficient.
### 7. Drizzle Kit migrations
- Use `drizzle-kit` for schema migrations
- `drizzle.config.ts` at `apps/aelis-backend/drizzle.config.ts`
- Migration files stored in `apps/aelis-backend/drizzle/`
- Scripts in `package.json`: `db:generate`, `db:migrate`, `db:studio`
## Acceptance Criteria
1. **Bun.sql driver**
- [ ] `pg` and `@types/pg` are removed from `package.json`
- [ ] `db.ts` exports a Drizzle instance using `Bun.sql`
- [ ] All existing DB usage (Better Auth) works with the new driver
2. **Better Auth on Drizzle**
- [ ] Better Auth uses `drizzle-adapter` with the shared Drizzle instance
- [ ] Auth tables (user, session, account, verification) are defined in the Drizzle schema
- [ ] Signup, signin, and session validation work as before
3. **User sources table**
- [ ] `user_sources` table exists with the schema described above
- [ ] Unique constraint on `(user_id, source_id)` is enforced
- [ ] `config` column accepts arbitrary JSON
- [ ] `credentials` column stores encrypted bytes
4. **Credential encryption**
- [ ] Encrypt/decrypt utility works with AES-256-GCM
- [ ] IV is unique per encryption
- [ ] Missing `CREDENTIALS_ENCRYPTION_KEY` fails fast at startup
- [ ] Unit tests cover round-trip encrypt → decrypt
5. **Default source seeding**
- [ ] New user signup creates `user_sources` rows for location, weather, and TFL
- [ ] Default config values match the table above
- [ ] Sources requiring credentials are not auto-enabled
6. **Provider DB integration**
- [ ] Each provider queries `user_sources` for the user's config and credentials
- [ ] Disabled sources (enabled=false or missing row) throw `SourceDisabledError`, excluded via `Promise.allSettled`
- [ ] Invalid config logs an error and skips the source (graceful degradation)
- [ ] `SourceDisabledError` class is created in `src/session/`
_Note: `FeedSourceProvider` is already async, `UserSessionManager.getOrCreate` is already async with in-flight deduplication and `Promise.allSettled` graceful degradation. No interface changes needed._
7. **Migrations**
- [ ] `drizzle.config.ts` is configured
- [ ] Initial migration creates all tables (auth + user_sources)
- [ ] `bun run db:generate` and `bun run db:migrate` work
## Implementation Approach
### Phase 1: Drizzle + Bun.sql setup
1. Install `drizzle-orm` and `drizzle-kit`; remove `pg` and `@types/pg`
2. Create `src/db/index.ts` — Drizzle instance with `Bun.sql`
3. Create `src/db/schema.ts` — Better Auth tables + `user_sources` table
4. Create `drizzle.config.ts`
5. Add `db:generate`, `db:migrate`, `db:studio` scripts to `package.json`
### Phase 2: Better Auth migration
6. Update `src/auth/index.ts` to use `drizzle-adapter` with the Drizzle instance
7. Verify signup/signin/session validation still work
8. Remove old `src/db.ts` (raw Pool)
### Phase 3: Credential encryption
9. Create `src/lib/crypto.ts` with `encrypt` and `decrypt` functions (AES-256-GCM)
10. Add `CREDENTIALS_ENCRYPTION_KEY` to `.env.example`
11. Write unit tests for encrypt/decrypt round-trip
### Phase 4: User source config
12. Create `src/db/user-sources.ts` — query helpers (get sources for user, upsert config, etc.)
13. Create `src/session/source-disabled-error.ts``SourceDisabledError` class
14. Implement default source seeding on user creation
15. Update each provider (Weather, TFL, Location) to accept Drizzle DB instance and query `user_sources` for config/credentials
_`FeedSourceProvider` is already async and `UserSessionManager.getOrCreate` already handles provider failures via `Promise.allSettled`. No interface or caller changes needed._
### Phase 5: Verification
16. Generate and run initial migration
17. Run existing tests, fix any breakage
18. Manual test: signup → default sources created → feed returns data
## File Structure (new/modified)
```
apps/aelis-backend/
├── drizzle.config.ts # NEW
├── drizzle/ # NEW — migration files
├── src/
│ ├── db.ts # REPLACE — Drizzle + Bun.sql
│ ├── db/
│ │ ├── schema.ts # NEW — all table definitions
│ │ └── user-sources.ts # NEW — query helpers
│ ├── auth/
│ │ └── index.ts # MODIFY — drizzle adapter
│ ├── lib/
│ │ ├── crypto.ts # NEW — encrypt/decrypt
│ │ └── crypto.test.ts # NEW
│ ├── session/
│ │ └── source-disabled-error.ts # NEW — SourceDisabledError
│ ├── weather/
│ │ └── provider.ts # MODIFY — query DB
│ └── tfl/
│ └── provider.ts # MODIFY — query DB
```
_`feed-source-provider.ts`, `user-session-manager.ts`, `engine/http.ts`, and `location/http.ts` are already async-ready on master and do not need changes._
## Dependencies
**Add:**
- `drizzle-orm`
- `drizzle-kit` (dev)
**Remove:**
- `pg`
- `@types/pg` (dev)
## Environment Variables
**Add to `.env.example`:**
- `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM
## Open Questions (Deferred)
- HTTP endpoints for CRUD on user source config (settings UI)
- OAuth flow for connecting Google Calendar / CalDAV accounts
- Source config validation schemas exported from each source package (currently only TFL has one)
- Whether to cache DB-loaded config in the UserSession to avoid repeated queries on reconnect

View File

@@ -180,31 +180,6 @@ describe("FeedEngine", () => {
expect(engine.refresh()).resolves.toBeDefined()
})
test("register invalidates feed cache", async () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
engine.register(createWeatherSource())
expect(engine.lastFeed()).toBeNull()
})
test("unregister invalidates feed cache", async () => {
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine().register(location).register(weather)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
engine.unregister("weather")
expect(engine.lastFeed()).toBeNull()
})
})
describe("graph validation", () => {
@@ -959,54 +934,4 @@ describe("FeedEngine", () => {
engine.stop()
})
})
describe("invalidateCache", () => {
test("clears cached result", async () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
await engine.refresh()
expect(engine.lastFeed()).not.toBeNull()
engine.invalidateCache()
expect(engine.lastFeed()).toBeNull()
})
test("is safe to call when no cache exists", () => {
const engine = new FeedEngine()
expect(() => engine.invalidateCache()).not.toThrow()
expect(engine.lastFeed()).toBeNull()
})
})
describe("isStarted", () => {
test("returns false before start", () => {
const engine = new FeedEngine()
expect(engine.isStarted()).toBe(false)
})
test("returns true after start", () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
engine.start()
expect(engine.isStarted()).toBe(true)
engine.stop()
})
test("returns false after stop", () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
engine.start()
engine.stop()
expect(engine.isStarted()).toBe(false)
})
})
})

View File

@@ -97,33 +97,23 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
/**
* Registers a FeedSource. Invalidates the cached graph and feed cache.
* Registers a FeedSource. Invalidates the cached graph.
*/
register<TItem extends FeedItem>(source: FeedSource<TItem>): FeedEngine<TItems | TItem> {
this.sources.set(source.id, source)
this.graph = null
this.invalidateCache()
return this as FeedEngine<TItems | TItem>
}
/**
* Unregisters a FeedSource by ID. Invalidates the cached graph and feed cache.
* Unregisters a FeedSource by ID. Invalidates the cached graph.
*/
unregister(sourceId: string): this {
this.sources.delete(sourceId)
this.graph = null
this.invalidateCache()
return this
}
/**
* Clears the cached feed result so the next access triggers a fresh refresh.
*/
invalidateCache(): void {
this.cachedResult = null
this.cachedAt = null
}
/**
* Registers a post-processor. Processors run in registration order
* after items are collected, on every update path.
@@ -259,13 +249,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.cleanups = []
}
/**
* Returns whether the engine is currently running reactive subscriptions.
*/
isStarted(): boolean {
return this.started
}
/**
* Returns the current accumulated context.
*/