From 91a4b9fe1ee22ebe36ea381658ed297c89c3f31f Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 23 May 2026 02:25:50 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20agentic-server=20package=20?= =?UTF-8?q?=E2=80=94=20standalone=20Express=20LLM=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Express-only equivalent of graphile-llm: agent threads, chat streaming, billing metering, and inference logging. Uses @constructive-io/express-context for tenant-scoped database access. Endpoints: - POST /v1/threads — create conversation thread - POST /v1/threads/:id/messages — send message + stream response (SSE) - POST /v1/orgs/:entity_id/threads — entity-scoped thread creation - POST /v1/orgs/:entity_id/threads/:id/messages — entity-scoped messaging - POST /v1/embed — generate embeddings Features: - OllamaAdapter for chat (streaming + batch) and embeddings - Automatic billing quota check + usage recording - Inference logging (token counts, latency, model) - TTL-based discovery caching (no graphile-cache dependency) - OpenAI-compatible SSE streaming format --- packages/agentic-server/README.md | 71 ++ .../agentic-server/__tests__/cache.test.ts | 52 ++ packages/agentic-server/__tests__/env.test.ts | 47 ++ packages/agentic-server/jest.config.js | 18 + packages/agentic-server/package.json | 58 ++ packages/agentic-server/src/billing.ts | 97 +++ packages/agentic-server/src/cache.ts | 46 ++ packages/agentic-server/src/discovery.ts | 181 +++++ packages/agentic-server/src/env.ts | 50 ++ packages/agentic-server/src/index.ts | 34 + packages/agentic-server/src/router.ts | 657 ++++++++++++++++++ packages/agentic-server/tsconfig.esm.json | 9 + packages/agentic-server/tsconfig.json | 9 + pnpm-lock.yaml | 131 +++- 14 files changed, 1428 insertions(+), 32 deletions(-) create mode 100644 packages/agentic-server/README.md create mode 100644 packages/agentic-server/__tests__/cache.test.ts create mode 100644 packages/agentic-server/__tests__/env.test.ts create mode 100644 packages/agentic-server/jest.config.js create mode 100644 packages/agentic-server/package.json create mode 100644 packages/agentic-server/src/billing.ts create mode 100644 packages/agentic-server/src/cache.ts create mode 100644 packages/agentic-server/src/discovery.ts create mode 100644 packages/agentic-server/src/env.ts create mode 100644 packages/agentic-server/src/index.ts create mode 100644 packages/agentic-server/src/router.ts create mode 100644 packages/agentic-server/tsconfig.esm.json create mode 100644 packages/agentic-server/tsconfig.json diff --git a/packages/agentic-server/README.md b/packages/agentic-server/README.md new file mode 100644 index 0000000000..23f79f800a --- /dev/null +++ b/packages/agentic-server/README.md @@ -0,0 +1,71 @@ +# agentic-server + +Standalone Express LLM service — agent threads, chat streaming, billing metering, and inference logging via `@constructive-io/express-context`. + +## Overview + +`agentic-server` is the Express-only equivalent of what `graphile-llm` does inside PostGraphile. It provides the same capabilities (agent threads, chat, embeddings, billing, inference logging) but as a standalone Express router that uses `@constructive-io/express-context` for tenant-scoped database access. + +## Usage + +```typescript +import express from 'express'; +import { createContextMiddleware } from '@constructive-io/express-context'; +import { createAgenticRouter } from 'agentic-server'; + +const app = express(); + +// Tenant context middleware (domain resolution, JWT, pgSettings, withPgClient) +app.use(createContextMiddleware()); + +// Mount the agentic router +app.use(createAgenticRouter()); + +app.listen(3001, () => { + console.log('agentic-server running on :3001'); +}); +``` + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/v1/threads` | Create a new conversation thread | +| POST | `/v1/threads/:thread_id/messages` | Send messages + get AI response (streaming SSE) | +| POST | `/v1/orgs/:entity_id/threads` | Create thread (entity-scoped) | +| POST | `/v1/orgs/:entity_id/threads/:thread_id/messages` | Send message (entity-scoped) | +| POST | `/v1/embed` | Generate embeddings | + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CHAT_PROVIDER` | `ollama` | Chat LLM provider | +| `CHAT_MODEL` | `llama3` | Default chat model | +| `CHAT_BASE_URL` | `http://localhost:11434` | Chat provider URL | +| `EMBEDDER_PROVIDER` | `ollama` | Embedding provider | +| `EMBEDDER_MODEL` | `nomic-embed-text` | Default embedding model | +| `EMBEDDER_BASE_URL` | `http://localhost:11434` | Embedding provider URL | + +## Features + +- **Thread management**: Create and manage conversation threads with system prompts +- **Streaming chat**: SSE streaming responses (OpenAI-compatible format) +- **Batch chat**: Non-streaming JSON responses +- **Embeddings**: Generate vector embeddings via `/v1/embed` +- **Billing integration**: Automatic quota checks and usage recording (when billing module is provisioned) +- **Inference logging**: Automatic token usage logging (when inference_log module is provisioned) +- **RLS enforcement**: All database operations run within tenant-scoped RLS transactions + +## Architecture + +``` +Cloud Function → POST /v1/threads/:id/messages → agentic-server + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + express-context OllamaAdapter Billing + (tenant DB context) (LLM provider) (quota + usage) +``` + +The cloud function doesn't need to know about databases, billing, or LLM providers. It just POSTs `{ messages, model }` and gets back a streamed response. diff --git a/packages/agentic-server/__tests__/cache.test.ts b/packages/agentic-server/__tests__/cache.test.ts new file mode 100644 index 0000000000..1707988ed3 --- /dev/null +++ b/packages/agentic-server/__tests__/cache.test.ts @@ -0,0 +1,52 @@ +import { TtlCache } from '../src/cache'; + +describe('TtlCache', () => { + it('stores and retrieves values', () => { + const cache = new TtlCache(60_000); + cache.set('key1', 'value1'); + expect(cache.get('key1')).toBe('value1'); + }); + + it('returns undefined for missing keys', () => { + const cache = new TtlCache(60_000); + expect(cache.get('missing')).toBeUndefined(); + }); + + it('expires entries after TTL', () => { + const cache = new TtlCache(1); // 1ms TTL + cache.set('key1', 'value1'); + + // Wait for TTL to expire + const start = Date.now(); + while (Date.now() - start < 5) { + // spin + } + + expect(cache.get('key1')).toBeUndefined(); + }); + + it('deletes entries', () => { + const cache = new TtlCache(60_000); + cache.set('key1', 'value1'); + cache.delete('key1'); + expect(cache.get('key1')).toBeUndefined(); + }); + + it('clears all entries', () => { + const cache = new TtlCache(60_000); + cache.set('a', '1'); + cache.set('b', '2'); + expect(cache.size).toBe(2); + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.get('a')).toBeUndefined(); + }); + + it('tracks size correctly', () => { + const cache = new TtlCache(60_000); + expect(cache.size).toBe(0); + cache.set('x', 1); + cache.set('y', 2); + expect(cache.size).toBe(2); + }); +}); diff --git a/packages/agentic-server/__tests__/env.test.ts b/packages/agentic-server/__tests__/env.test.ts new file mode 100644 index 0000000000..f3ce502d48 --- /dev/null +++ b/packages/agentic-server/__tests__/env.test.ts @@ -0,0 +1,47 @@ +import { getEnvOptions } from '../src/env'; + +describe('getEnvOptions', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('returns defaults when no env vars set', () => { + delete process.env.CHAT_PROVIDER; + delete process.env.CHAT_MODEL; + delete process.env.CHAT_BASE_URL; + delete process.env.EMBEDDER_PROVIDER; + delete process.env.EMBEDDER_MODEL; + delete process.env.EMBEDDER_BASE_URL; + + const opts = getEnvOptions(); + expect(opts.chat.provider).toBe('ollama'); + expect(opts.chat.model).toBe('llama3'); + expect(opts.chat.baseUrl).toBe('http://localhost:11434'); + expect(opts.embedding.provider).toBe('ollama'); + expect(opts.embedding.model).toBe('nomic-embed-text'); + expect(opts.embedding.baseUrl).toBe('http://localhost:11434'); + }); + + it('reads from env vars', () => { + process.env.CHAT_PROVIDER = 'openai'; + process.env.CHAT_MODEL = 'gpt-4'; + process.env.CHAT_BASE_URL = 'https://api.openai.com'; + process.env.EMBEDDER_PROVIDER = 'cohere'; + process.env.EMBEDDER_MODEL = 'embed-v3'; + process.env.EMBEDDER_BASE_URL = 'https://api.cohere.ai'; + + const opts = getEnvOptions(); + expect(opts.chat.provider).toBe('openai'); + expect(opts.chat.model).toBe('gpt-4'); + expect(opts.chat.baseUrl).toBe('https://api.openai.com'); + expect(opts.embedding.provider).toBe('cohere'); + expect(opts.embedding.model).toBe('embed-v3'); + expect(opts.embedding.baseUrl).toBe('https://api.cohere.ai'); + }); +}); diff --git a/packages/agentic-server/jest.config.js b/packages/agentic-server/jest.config.js new file mode 100644 index 0000000000..057a9420ed --- /dev/null +++ b/packages/agentic-server/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/packages/agentic-server/package.json b/packages/agentic-server/package.json new file mode 100644 index 0000000000..e9201696aa --- /dev/null +++ b/packages/agentic-server/package.json @@ -0,0 +1,58 @@ +{ + "name": "agentic-server", + "version": "0.1.0", + "description": "Standalone Express LLM service — agent threads, chat streaming, billing metering, and inference logging via express-context", + "author": "Constructive ", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest --forceExit", + "test:watch": "jest --watch" + }, + "dependencies": { + "@agentic-kit/ollama": "^2.0.0", + "@constructive-io/express-context": "workspace:^", + "@pgpmjs/logger": "workspace:^", + "pg": "^8.21.0", + "pg-cache": "workspace:^" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^22.19.11", + "@types/pg": "^8.20.0", + "express": "^5.2.1", + "makage": "^0.3.0" + }, + "peerDependencies": { + "express": "^5.0.0" + }, + "keywords": [ + "agentic-kit", + "express", + "llm", + "ollama", + "streaming", + "billing", + "metering", + "constructive" + ] +} diff --git a/packages/agentic-server/src/billing.ts b/packages/agentic-server/src/billing.ts new file mode 100644 index 0000000000..b6f0895f13 --- /dev/null +++ b/packages/agentic-server/src/billing.ts @@ -0,0 +1,97 @@ +/** + * billing — Quota check and usage recording via tenant database + * + * Calls the billing functions discovered from billing_module. + * Gracefully allows requests if billing is not provisioned or errors. + */ + +import { Logger } from '@pgpmjs/logger'; +import type { ConstructiveContext } from '@constructive-io/express-context'; + +import type { BillingConfig, InferenceLogConfig } from './discovery'; + +const log = new Logger('agentic-server:billing'); + +// ─── Quota Check ──────────────────────────────────────────────────────────── + +export async function checkQuota( + ctx: ConstructiveContext, + billing: BillingConfig, + entityId: string, + meterSlug: string, +): Promise { + try { + return await ctx.withPgClient(async (client) => { + const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`; + const result = await client.query(sql, [meterSlug, entityId, 1]); + return result.rows[0]?.allowed !== false; + }); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + log.warn(`check_billing_quota failed (allowing): ${message}`); + return true; + } +} + +// ─── Usage Recording ──────────────────────────────────────────────────────── + +export async function recordUsage( + ctx: ConstructiveContext, + billing: BillingConfig, + entityId: string, + meterSlug: string, + amount: number, + metadata: Record, +): Promise { + try { + await ctx.withPgClient(async (client) => { + const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`; + await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]); + }); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + log.warn(`record_usage failed (non-fatal): ${message}`); + } +} + +// ─── Inference Logging ────────────────────────────────────────────────────── + +export interface InferenceLogEntry { + entityId: string; + actorId: string; + model: string; + provider: string; + service: string; + operation: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; + latencyMs: number; + status: string; +} + +export async function logInference( + ctx: ConstructiveContext, + logConfig: InferenceLogConfig, + entry: InferenceLogEntry, +): Promise { + try { + await ctx.withPgClient(async (client) => { + await client.query( + `INSERT INTO "${logConfig.schema}"."${logConfig.tableName}" + (entity_id, actor_id, model, provider, service, operation, + input_tokens, output_tokens, total_tokens, latency_ms, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + entry.entityId, entry.actorId, entry.model, + entry.provider, entry.service, entry.operation, + entry.inputTokens, entry.outputTokens, entry.totalTokens, + entry.latencyMs, entry.status, + ], + ); + }); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + log.warn(`inference log INSERT failed (non-fatal): ${message}`); + } +} diff --git a/packages/agentic-server/src/cache.ts b/packages/agentic-server/src/cache.ts new file mode 100644 index 0000000000..9aec6e49bd --- /dev/null +++ b/packages/agentic-server/src/cache.ts @@ -0,0 +1,46 @@ +/** + * cache — Simple TTL cache for module discovery results + * + * Avoids a dependency on graphile-cache by providing a minimal + * Map + TTL implementation. Entries expire after the configured TTL. + */ + +interface CacheEntry { + value: T; + expiresAt: number; +} + +export class TtlCache { + private store = new Map>(); + private ttlMs: number; + + constructor(ttlMs: number) { + this.ttlMs = ttlMs; + } + + get(key: string): T | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + return entry.value; + } + + set(key: string, value: T): void { + this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + } + + delete(key: string): void { + this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } + + get size(): number { + return this.store.size; + } +} diff --git a/packages/agentic-server/src/discovery.ts b/packages/agentic-server/src/discovery.ts new file mode 100644 index 0000000000..528d49c5cf --- /dev/null +++ b/packages/agentic-server/src/discovery.ts @@ -0,0 +1,181 @@ +/** + * discovery — Runtime resolution of agent and billing module tables + * + * Queries metaschema_modules_public to find: + * - agent_chat_module: thread/message/task table names + * - billing_module: billing function names (check_quota, record_usage) + * - inference_log_module: inference log table name + * + * Results are cached with a TTL to avoid per-request database hits. + */ + +import type { Pool } from 'pg'; + +import { TtlCache } from './cache'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface AgentTableInfo { + schemaName: string; + tableName: string; +} + +export interface AgentDiscovery { + thread: AgentTableInfo | null; + message: AgentTableInfo | null; + task: AgentTableInfo | null; +} + +export interface BillingConfig { + privateSchema: string; + publicSchema: string; + recordUsageFunction: string; + checkBillingQuotaFunction: string; +} + +export interface InferenceLogConfig { + schema: string; + tableName: string; +} + +export interface DatabaseConfig { + billing: BillingConfig | null; + inferenceLog: InferenceLogConfig | null; +} + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const AGENT_DISCOVERY_SQL = ` + SELECT + s.schema_name, + acm.thread_table_name, + acm.message_table_name, + acm.task_table_name + FROM metaschema_modules_public.agent_chat_module acm + JOIN metaschema_public.schema s ON s.id = acm.schema_id + LIMIT 1 +`; + +const SCHEMA_EXISTS_SQL = ` + SELECT 1 FROM information_schema.schemata WHERE schema_name = $1 LIMIT 1 +`; + +const BILLING_MODULE_SQL = ` + SELECT + s.schema_name AS public_schema, + ps.schema_name AS private_schema, + bm.record_usage_function + FROM metaschema_modules_public.billing_module bm + JOIN metaschema_public.schema s ON bm.schema_id = s.id + JOIN metaschema_public.schema ps ON bm.private_schema_id = ps.id + WHERE bm.database_id = $1 + LIMIT 1 +`; + +const INFERENCE_LOG_MODULE_SQL = ` + SELECT + s.schema_name AS schema, + ilm.inference_log_table_name AS table_name + FROM metaschema_modules_public.inference_log_module ilm + JOIN metaschema_public.schema s ON ilm.schema_id = s.id + WHERE ilm.database_id = $1 + LIMIT 1 +`; + +// ─── Caches ───────────────────────────────────────────────────────────────── + +const agentCache = new TtlCache(60_000); // 1 minute +const configCache = new TtlCache(5 * 60_000); // 5 minutes + +// ─── Agent Discovery ──────────────────────────────────────────────────────── + +export async function getAgentDiscovery( + pool: Pool, + dbname: string, +): Promise { + const cached = agentCache.get(dbname); + if (cached !== undefined) return cached; + + let discovery: AgentDiscovery | null = null; + + try { + const { rows } = await pool.query(AGENT_DISCOVERY_SQL); + if (rows.length > 0) { + const row = rows[0]; + const schemaName: string = row.schema_name; + discovery = { + thread: row.thread_table_name + ? { schemaName, tableName: row.thread_table_name } + : null, + message: row.message_table_name + ? { schemaName, tableName: row.message_table_name } + : null, + task: row.task_table_name + ? { schemaName, tableName: row.task_table_name } + : null, + }; + } + } catch { + // Module not provisioned in this database + } + + agentCache.set(dbname, discovery); + return discovery; +} + +// ─── Database Config (Billing + Inference Log) ────────────────────────────── + +export async function getDatabaseConfig( + pool: Pool, + databaseId: string, +): Promise { + const cached = configCache.get(databaseId); + if (cached !== undefined) return cached; + + let billing: BillingConfig | null = null; + let inferenceLog: InferenceLogConfig | null = null; + + try { + const schemaCheck = await pool.query(SCHEMA_EXISTS_SQL, ['metaschema_modules_public']); + if (schemaCheck.rows.length > 0) { + const [billingResult, logResult] = await Promise.all([ + pool.query(BILLING_MODULE_SQL, [databaseId]).catch(() => ({ rows: [] as any[] })), + pool.query(INFERENCE_LOG_MODULE_SQL, [databaseId]).catch(() => ({ rows: [] as any[] })), + ]); + + const bRow = billingResult.rows[0]; + if (bRow?.record_usage_function) { + billing = { + publicSchema: bRow.public_schema as string, + privateSchema: bRow.private_schema as string, + recordUsageFunction: bRow.record_usage_function as string, + checkBillingQuotaFunction: 'check_billing_quota', + }; + } + + const lRow = logResult.rows[0]; + if (lRow?.schema && lRow?.table_name) { + inferenceLog = { + schema: lRow.schema as string, + tableName: lRow.table_name as string, + }; + } + } + } catch { + // metaschema not provisioned + } + + const entry: DatabaseConfig = { billing, inferenceLog }; + configCache.set(databaseId, entry); + return entry; +} + +// ─── Cache Management ─────────────────────────────────────────────────────── + +export function clearAgentCache(): void { + agentCache.clear(); +} + +export function clearConfigCache(): void { + configCache.clear(); +} diff --git a/packages/agentic-server/src/env.ts b/packages/agentic-server/src/env.ts new file mode 100644 index 0000000000..e9fb61ba63 --- /dev/null +++ b/packages/agentic-server/src/env.ts @@ -0,0 +1,50 @@ +/** + * env — LLM provider configuration from environment variables + * + * Environment variables: + * EMBEDDER_PROVIDER - Embedding provider ('ollama') + * EMBEDDER_MODEL - Embedding model (default: 'nomic-embed-text') + * EMBEDDER_BASE_URL - Embedding provider URL (default: 'http://localhost:11434') + * CHAT_PROVIDER - Chat provider ('ollama') + * CHAT_MODEL - Chat model (default: 'llama3') + * CHAT_BASE_URL - Chat provider URL (default: 'http://localhost:11434') + */ + +const DEFAULTS = { + embedding: { + provider: 'ollama', + model: 'nomic-embed-text', + baseUrl: 'http://localhost:11434', + }, + chat: { + provider: 'ollama', + model: 'llama3', + baseUrl: 'http://localhost:11434', + }, +} as const; + +export interface ProviderConfig { + provider: string; + model: string; + baseUrl: string; +} + +export interface EnvOptions { + embedding: ProviderConfig; + chat: ProviderConfig; +} + +export function getEnvOptions(): EnvOptions { + return { + embedding: { + provider: process.env.EMBEDDER_PROVIDER ?? DEFAULTS.embedding.provider, + model: process.env.EMBEDDER_MODEL ?? DEFAULTS.embedding.model, + baseUrl: process.env.EMBEDDER_BASE_URL ?? DEFAULTS.embedding.baseUrl, + }, + chat: { + provider: process.env.CHAT_PROVIDER ?? DEFAULTS.chat.provider, + model: process.env.CHAT_MODEL ?? DEFAULTS.chat.model, + baseUrl: process.env.CHAT_BASE_URL ?? DEFAULTS.chat.baseUrl, + }, + }; +} diff --git a/packages/agentic-server/src/index.ts b/packages/agentic-server/src/index.ts new file mode 100644 index 0000000000..ee35f8e057 --- /dev/null +++ b/packages/agentic-server/src/index.ts @@ -0,0 +1,34 @@ +/** + * agentic-server — Standalone Express LLM service + * + * Express-only equivalent of graphile-llm: agent threads, chat streaming, + * billing metering, and inference logging. Uses @constructive-io/express-context + * for tenant-scoped database access. + * + * @example + * ```typescript + * import express from 'express'; + * import { createContextMiddleware } from '@constructive-io/express-context'; + * import { createAgenticRouter } from 'agentic-server'; + * + * const app = express(); + * app.use(createContextMiddleware()); + * app.use(createAgenticRouter()); + * app.listen(3001); + * ``` + */ + +export { createAgenticRouter } from './router'; +export { getEnvOptions } from './env'; +export type { EnvOptions, ProviderConfig } from './env'; +export { getAgentDiscovery, getDatabaseConfig, clearAgentCache, clearConfigCache } from './discovery'; +export type { + AgentDiscovery, + AgentTableInfo, + BillingConfig, + DatabaseConfig, + InferenceLogConfig, +} from './discovery'; +export { checkQuota, recordUsage, logInference } from './billing'; +export type { InferenceLogEntry } from './billing'; +export { TtlCache } from './cache'; diff --git a/packages/agentic-server/src/router.ts b/packages/agentic-server/src/router.ts new file mode 100644 index 0000000000..cd447fd553 --- /dev/null +++ b/packages/agentic-server/src/router.ts @@ -0,0 +1,657 @@ +/** + * router — Express router for the agentic-server + * + * Provides REST endpoints for AI agent conversations: + * + * POST /v1/threads → create thread + * POST /v1/threads/:thread_id/messages → send message + stream response + * POST /v1/orgs/:entity_id/threads → create thread (entity-scoped) + * POST /v1/orgs/:entity_id/threads/:thread_id/messages → send message (entity-scoped) + * POST /v1/embed → generate embedding + * + * All routes require `req.constructive` (from @constructive-io/express-context). + * Billing (check_quota + record_usage) and inference logging are automatic + * when the billing/inference_log modules are provisioned. + */ + +import express, { Router, Request, Response } from 'express'; +import { Logger } from '@pgpmjs/logger'; +import { OllamaAdapter } from '@agentic-kit/ollama'; + +import { checkQuota, logInference, recordUsage } from './billing'; +import type { InferenceLogEntry } from './billing'; +import { getAgentDiscovery, getDatabaseConfig } from './discovery'; +import type { AgentDiscovery, BillingConfig, InferenceLogConfig } from './discovery'; +import { getEnvOptions } from './env'; + +const log = new Logger('agentic-server'); + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface ThreadRow { + id: string; + mode: string; + model: string | null; + system_prompt: string | null; + status: string; +} + +interface MessageRow { + id: string; + author_role: string; + parts: any; + created_at: string; +} + +interface CreateThreadBody { + mode?: string; + model?: string; + system_prompt?: string; + title?: string; +} + +interface SendMessageBody { + messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>; + model?: string; + temperature?: number; + stream?: boolean; +} + +interface EmbedBody { + input: string | string[]; + model?: string; +} + +interface UsageResult { + input: number; + output: number; + reasoning: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function resolveOllamaAdapter(): { adapter: OllamaAdapter; model: string; baseUrl: string } | null { + const { chat } = getEnvOptions(); + if (chat.provider === 'ollama') { + return { + adapter: new OllamaAdapter(chat.baseUrl), + model: chat.model, + baseUrl: chat.baseUrl, + }; + } + return null; +} + +function resolveEmbeddingAdapter(): { adapter: OllamaAdapter; model: string } | null { + const { embedding } = getEnvOptions(); + if (embedding.provider === 'ollama') { + return { + adapter: new OllamaAdapter(embedding.baseUrl), + model: embedding.model, + }; + } + return null; +} + +// ─── Route Handlers ───────────────────────────────────────────────────────── + +async function handleCreateThread( + req: Request, + res: Response, + entityId: string, +): Promise { + const ctx = req.constructive; + if (!ctx?.userId) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + if (!ctx.api.dbname) { + res.status(400).json({ error: 'Database not resolved' }); + return; + } + + const discovery = await getAgentDiscovery(ctx.pool, ctx.api.dbname); + if (!discovery?.thread) { + res.status(404).json({ error: 'Agent module not provisioned for this database' }); + return; + } + + const body: CreateThreadBody = req.body || {}; + const { thread } = discovery; + + const result = await ctx.withPgClient(async (client) => { + const { rows } = await client.query( + `INSERT INTO "${thread.schemaName}"."${thread.tableName}" + (entity_id, owner_id, mode, model, system_prompt, title) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, mode, model, system_prompt, status, created_at`, + [ + entityId, + ctx.userId, + body.mode ?? 'ask', + body.model ?? null, + body.system_prompt ?? null, + body.title ?? null, + ], + ); + return rows[0]; + }); + + res.status(201).json({ + id: result.id, + mode: result.mode, + model: result.model, + system_prompt: result.system_prompt, + status: result.status, + created_at: result.created_at, + }); +} + +async function handleSendMessage( + req: Request, + res: Response, + entityId: string, +): Promise { + const ctx = req.constructive; + if (!ctx?.userId) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + if (!ctx.api.dbname) { + res.status(400).json({ error: 'Database not resolved' }); + return; + } + + const discovery = await getAgentDiscovery(ctx.pool, ctx.api.dbname); + if (!discovery?.thread || !discovery?.message) { + res.status(404).json({ error: 'Agent module not provisioned for this database' }); + return; + } + + const body: SendMessageBody = req.body || {}; + if (!body.messages?.length) { + res.status(400).json({ error: 'messages[] is required and must not be empty' }); + return; + } + + const { thread, message: msgTable } = discovery; + const threadId = req.params.thread_id; + const userId = ctx.userId; + + // Verify thread exists (RLS enforced) + const threadRow = await ctx.withPgClient(async (client) => { + const { rows } = await client.query( + `SELECT id, mode, model, system_prompt, status + FROM "${thread.schemaName}"."${thread.tableName}" + WHERE id = $1`, + [threadId], + ); + return rows[0] as ThreadRow | undefined; + }); + + if (!threadRow) { + res.status(404).json({ error: 'Thread not found' }); + return; + } + + // Resolve billing + inference log config + const dbConfig = ctx.databaseId + ? await getDatabaseConfig(ctx.pool, ctx.databaseId) + : { billing: null, inferenceLog: null }; + + const ollama = resolveOllamaAdapter(); + if (!ollama) { + res.status(503).json({ error: 'No LLM provider configured' }); + return; + } + + const model = body.model ?? threadRow.model ?? ollama.model; + const meterSlug = model; + + // Quota check + if (dbConfig.billing) { + const allowed = await checkQuota(ctx, dbConfig.billing, entityId, meterSlug); + if (!allowed) { + res.status(429).json({ + error: 'Token quota exceeded', + meter: meterSlug, + entity_id: entityId, + }); + return; + } + } + + // Persist user messages + await ctx.withPgClient(async (client) => { + for (const msg of body.messages) { + if (msg.role === 'user') { + await client.query( + `INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}" + (thread_id, owner_id, entity_id, author_role, parts) + VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4)`, + [threadId, userId, 'user', JSON.stringify([{ type: 'text', text: msg.content }])], + ); + } + } + }); + + // Load full thread history + const history = await ctx.withPgClient(async (client) => { + const { rows } = await client.query( + `SELECT author_role, parts, created_at + FROM "${msgTable.schemaName}"."${msgTable.tableName}" + WHERE thread_id = $1 + ORDER BY created_at ASC`, + [threadId], + ); + return rows as MessageRow[]; + }); + + const llmMessages: Array<{ role: string; content: string }> = []; + if (threadRow.system_prompt) { + llmMessages.push({ role: 'system', content: threadRow.system_prompt }); + } + for (const row of history) { + const parts = Array.isArray(row.parts) ? row.parts : []; + const textContent = parts + .filter((p: any) => p.type === 'text') + .map((p: any) => p.text) + .join(''); + if (textContent) { + llmMessages.push({ + role: row.author_role === 'user' ? 'user' : 'assistant', + content: textContent, + }); + } + } + + const startTime = Date.now(); + const shouldStream = body.stream !== false; + + if (shouldStream) { + await handleStreamingResponse(req, res, { + ctx, ollama, model, llmMessages, body, + entityId, userId, threadId, + thread, msgTable, dbConfig, startTime, meterSlug, + }); + } else { + await handleBatchResponse(req, res, { + ctx, ollama, model, llmMessages, body, + entityId, userId, threadId, + thread, msgTable, dbConfig, startTime, meterSlug, + }); + } +} + +interface MessageContext { + ctx: NonNullable; + ollama: { adapter: OllamaAdapter; model: string; baseUrl: string }; + model: string; + llmMessages: Array<{ role: string; content: string }>; + body: SendMessageBody; + entityId: string; + userId: string; + threadId: string; + thread: NonNullable; + msgTable: NonNullable; + dbConfig: { billing: BillingConfig | null; inferenceLog: InferenceLogConfig | null }; + startTime: number; + meterSlug: string; +} + +async function handleStreamingResponse( + _req: Request, + res: Response, + mc: MessageContext, +): Promise { + const { ctx, ollama, model, llmMessages, body, entityId, userId, threadId, thread, msgTable, dbConfig, startTime, meterSlug } = mc; + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + const messageId = `msg_${Date.now()}`; + + try { + const systemMsg = llmMessages.find(m => m.role === 'system'); + const nonSystem = llmMessages.filter(m => m.role !== 'system'); + const modelDesc = ollama.adapter.createModel(model, { maxOutputTokens: undefined }); + const context = { + systemPrompt: systemMsg?.content, + messages: nonSystem.map((m) => ({ + role: m.role as 'user', + content: m.content, + timestamp: Date.now(), + })), + }; + const stream = ollama.adapter.stream(modelDesc, context, { + temperature: body.temperature, + }); + + let streamedContent = ''; + for await (const event of stream) { + if (event.type === 'text_delta') { + streamedContent += event.delta; + const sseEvent = { + id: messageId, + choices: [{ + index: 0, + delta: { content: event.delta, role: 'assistant' }, + finish_reason: null as string | null, + }], + model, + }; + res.write(`data: ${JSON.stringify(sseEvent)}\n\n`); + } + } + + const result = await stream.result(); + const content = streamedContent; + const latencyMs = Date.now() - startTime; + const usage: UsageResult = { + input: result.usage.input, + output: result.usage.output, + reasoning: result.usage.reasoning, + cacheRead: result.usage.cacheRead, + cacheWrite: result.usage.cacheWrite, + totalTokens: result.usage.totalTokens, + }; + + res.write('data: [DONE]\n\n'); + res.end(); + + // Persist assistant message (fire-and-forget) + if (content) { + ctx.withPgClient(async (client) => { + await client.query( + `INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}" + (thread_id, owner_id, entity_id, author_role, parts, model) + VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, + [threadId, userId, 'assistant', JSON.stringify([{ type: 'text', text: content }]), model], + ); + }).catch((err) => log.error('Failed to persist assistant message:', err)); + } + + // Record billing usage (fire-and-forget) + if (dbConfig.billing && usage.totalTokens > 0) { + recordUsage(ctx, dbConfig.billing, entityId, meterSlug, usage.totalTokens, { + input_tokens: usage.input, + output_tokens: usage.output, + cache_read_tokens: usage.cacheRead, + cache_write_tokens: usage.cacheWrite, + model, + latency_ms: latencyMs, + stream: true, + }).catch(() => {}); + } + + // Inference log (fire-and-forget) + if (dbConfig.inferenceLog) { + logInference(ctx, dbConfig.inferenceLog, { + entityId, actorId: userId, model, provider: 'ollama', + service: 'llm', operation: 'chat', + inputTokens: usage.input, outputTokens: usage.output, + totalTokens: usage.totalTokens, latencyMs, status: 'ok', + }).catch(() => {}); + } + } catch (streamErr: any) { + log.error('Streaming error:', streamErr); + const errorEvent = { error: { message: streamErr.message, type: 'stream_error' } }; + res.write(`data: ${JSON.stringify(errorEvent)}\n\n`); + res.write('data: [DONE]\n\n'); + res.end(); + } +} + +async function handleBatchResponse( + _req: Request, + res: Response, + mc: MessageContext, +): Promise { + const { ctx, ollama, model, llmMessages, body, entityId, userId, threadId, thread, msgTable, dbConfig, startTime, meterSlug } = mc; + + const systemMsg = llmMessages.find(m => m.role === 'system'); + const nonSystem = llmMessages.filter(m => m.role !== 'system'); + const modelDesc = ollama.adapter.createModel(model, { maxOutputTokens: undefined }); + const context = { + systemPrompt: systemMsg?.content, + messages: nonSystem.map((m) => ({ + role: m.role as 'user', + content: m.content, + timestamp: Date.now(), + })), + }; + const stream = ollama.adapter.stream(modelDesc, context, { + temperature: body.temperature, + }); + + const result = await stream.result(); + const content = result.content + .filter((block): block is { type: 'text'; text: string } => block.type === 'text') + .map((block) => block.text) + .join(''); + const latencyMs = Date.now() - startTime; + const usage: UsageResult = { + input: result.usage.input, + output: result.usage.output, + reasoning: result.usage.reasoning, + cacheRead: result.usage.cacheRead, + cacheWrite: result.usage.cacheWrite, + totalTokens: result.usage.totalTokens, + }; + + // Persist assistant message + await ctx.withPgClient(async (client) => { + await client.query( + `INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}" + (thread_id, owner_id, entity_id, author_role, parts, model) + VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, + [threadId, userId, 'assistant', JSON.stringify([{ type: 'text', text: content }]), model], + ); + }); + + // Record billing + inference log (fire-and-forget) + if (dbConfig.billing && usage.totalTokens > 0) { + recordUsage(ctx, dbConfig.billing, entityId, meterSlug, usage.totalTokens, { + input_tokens: usage.input, + output_tokens: usage.output, + cache_read_tokens: usage.cacheRead, + cache_write_tokens: usage.cacheWrite, + model, + latency_ms: latencyMs, + stream: false, + }).catch(() => {}); + } + + if (dbConfig.inferenceLog) { + logInference(ctx, dbConfig.inferenceLog, { + entityId, actorId: userId, model, provider: 'ollama', + service: 'llm', operation: 'chat', + inputTokens: usage.input, outputTokens: usage.output, + totalTokens: usage.totalTokens, latencyMs, status: 'ok', + }).catch(() => {}); + } + + res.json({ + id: `msg_${Date.now()}`, + choices: [{ + index: 0, + message: { role: 'assistant', content }, + finish_reason: 'stop', + }], + model, + usage: { + prompt_tokens: usage.input, + completion_tokens: usage.output, + total_tokens: usage.totalTokens, + }, + }); +} + +// ─── Embedding Handler ────────────────────────────────────────────────────── + +async function handleEmbed(req: Request, res: Response): Promise { + const ctx = req.constructive; + if (!ctx?.userId) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + const body: EmbedBody = req.body || {}; + if (!body.input) { + res.status(400).json({ error: 'input is required' }); + return; + } + + const embedder = resolveEmbeddingAdapter(); + if (!embedder) { + res.status(503).json({ error: 'No embedding provider configured' }); + return; + } + + const model = body.model ?? embedder.model; + const inputs = Array.isArray(body.input) ? body.input : [body.input]; + + const dbConfig = ctx.databaseId + ? await getDatabaseConfig(ctx.pool, ctx.databaseId) + : { billing: null, inferenceLog: null }; + + // Quota check + if (dbConfig.billing) { + const allowed = await checkQuota(ctx, dbConfig.billing, ctx.userId, model); + if (!allowed) { + res.status(429).json({ error: 'Embedding quota exceeded', meter: model }); + return; + } + } + + const startTime = Date.now(); + + try { + const results = await Promise.all( + inputs.map((text) => embedder.adapter.embed(text, model)), + ); + + const latencyMs = Date.now() - startTime; + const totalTokens = results.reduce((sum, r) => sum + r.promptTokens, 0); + + // Record usage (fire-and-forget) + if (dbConfig.billing && totalTokens > 0) { + recordUsage(ctx, dbConfig.billing, ctx.userId, model, totalTokens, { + input_tokens: totalTokens, + model, + latency_ms: latencyMs, + batch_size: inputs.length, + }).catch(() => {}); + } + + if (dbConfig.inferenceLog) { + logInference(ctx, dbConfig.inferenceLog, { + entityId: ctx.userId, + actorId: ctx.userId, + model, + provider: 'ollama', + service: 'embedding', + operation: 'embed', + inputTokens: totalTokens, + outputTokens: 0, + totalTokens, + latencyMs, + status: 'ok', + }).catch(() => {}); + } + + res.json({ + object: 'list', + data: results.map((r, i) => ({ + object: 'embedding', + index: i, + embedding: r.embedding, + })), + model, + usage: { + prompt_tokens: totalTokens, + total_tokens: totalTokens, + }, + }); + } catch (err: any) { + log.error('Embedding error:', err); + res.status(500).json({ error: err.message ?? 'Embedding failed' }); + } +} + +// ─── Router Factory ───────────────────────────────────────────────────────── + +export function createAgenticRouter(): Router { + const router = Router(); + + router.use(express.json()); + + // Entity-scoped routes + router.post('/v1/orgs/:entity_id/threads', async (req: Request, res: Response) => { + try { + await handleCreateThread(req, res, req.params.entity_id); + } catch (err: any) { + log.error('Error creating thread:', err); + if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); + } + }); + + router.post('/v1/orgs/:entity_id/threads/:thread_id/messages', async (req: Request, res: Response) => { + try { + await handleSendMessage(req, res, req.params.entity_id); + } catch (err: any) { + log.error('Error in messages endpoint:', err); + if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); + } + }); + + // Global routes (entity_id = user_id from JWT) + router.post('/v1/threads', async (req: Request, res: Response) => { + try { + const userId = req.constructive?.userId; + if (!userId) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + await handleCreateThread(req, res, userId); + } catch (err: any) { + log.error('Error creating thread:', err); + if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); + } + }); + + router.post('/v1/threads/:thread_id/messages', async (req: Request, res: Response) => { + try { + const userId = req.constructive?.userId; + if (!userId) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + await handleSendMessage(req, res, userId); + } catch (err: any) { + log.error('Error in messages endpoint:', err); + if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); + } + }); + + // Embedding endpoint + router.post('/v1/embed', async (req: Request, res: Response) => { + try { + await handleEmbed(req, res); + } catch (err: any) { + log.error('Error in embed endpoint:', err); + if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); + } + }); + + return router; +} diff --git a/packages/agentic-server/tsconfig.esm.json b/packages/agentic-server/tsconfig.esm.json new file mode 100644 index 0000000000..aff046fb2a --- /dev/null +++ b/packages/agentic-server/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "es2022", + "moduleResolution": "bundler", + "declaration": false + } +} diff --git a/packages/agentic-server/tsconfig.json b/packages/agentic-server/tsconfig.json new file mode 100644 index 0000000000..1a9d5696cb --- /dev/null +++ b/packages/agentic-server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src/" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 576eb9dc1e..fc2b8b3519 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2091,6 +2091,41 @@ importers: version: 10.9.2(@types/node@22.19.11)(typescript@5.9.3) publishDirectory: dist + packages/agentic-server: + dependencies: + '@agentic-kit/ollama': + specifier: ^2.0.0 + version: 2.0.0 + '@constructive-io/express-context': + specifier: workspace:^ + version: link:../express-context/dist + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist + pg: + specifier: ^8.21.0 + version: 8.21.0 + pg-cache: + specifier: workspace:^ + version: link:../../postgres/pg-cache/dist + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^22.19.11 + version: 22.19.19 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + express: + specifier: ^5.2.1 + version: 5.2.1 + makage: + specifier: ^0.3.0 + version: 0.3.0 + publishDirectory: dist + packages/bucket-provisioner: dependencies: '@aws-sdk/client-s3': @@ -11542,7 +11577,7 @@ snapshots: '@jest/console@30.4.1': dependencies: '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 chalk: 4.1.2 jest-message-util: 30.4.1 jest-util: 30.4.1 @@ -11556,7 +11591,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 4.4.0 @@ -11564,7 +11599,7 @@ snapshots: fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + jest-config: 30.4.2(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) jest-haste-map: 30.4.1 jest-message-util: 30.4.1 jest-regex-util: 30.4.0 @@ -11592,7 +11627,7 @@ snapshots: dependencies: '@jest/fake-timers': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 jest-mock: 30.4.1 '@jest/expect-utils@30.2.0': @@ -11614,7 +11649,7 @@ snapshots: dependencies: '@jest/types': 30.4.1 '@sinonjs/fake-timers': 15.4.0 - '@types/node': 22.19.11 + '@types/node': 22.19.19 jest-message-util: 30.4.1 jest-mock: 30.4.1 jest-util: 30.4.1 @@ -11632,12 +11667,12 @@ snapshots: '@jest/pattern@30.0.1': dependencies: - '@types/node': 22.19.15 + '@types/node': 22.19.19 jest-regex-util: 30.0.1 '@jest/pattern@30.4.0': dependencies: - '@types/node': 22.19.11 + '@types/node': 22.19.19 jest-regex-util: 30.4.0 '@jest/reporters@30.4.1': @@ -11648,7 +11683,7 @@ snapshots: '@jest/transform': 30.4.1 '@jest/types': 30.4.1 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 22.19.11 + '@types/node': 22.19.19 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit-x: 0.2.2 @@ -11730,7 +11765,7 @@ snapshots: dependencies: '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.15 + '@types/node': 22.19.19 '@types/yargs': 15.0.20 chalk: 4.1.2 @@ -11740,7 +11775,7 @@ snapshots: '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.15 + '@types/node': 22.19.19 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -11750,7 +11785,7 @@ snapshots: '@jest/schemas': 30.4.1 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.11 + '@types/node': 22.19.19 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -11804,7 +11839,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 22.19.15 + '@types/node': 22.19.19 long: 5.3.2 '@launchql/styled-email@0.1.0(@babel/core@7.29.0)(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.6)(react@19.2.4)': @@ -13011,7 +13046,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 22.19.15 + '@types/node': 22.19.19 '@types/aria-query@4.2.2': {} @@ -13058,7 +13093,7 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 5.0.6 '@types/keygrip': 1.0.6 - '@types/node': 22.19.15 + '@types/node': 22.19.19 '@types/cors@2.8.19': dependencies: @@ -13131,7 +13166,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.9 - '@types/node': 22.19.15 + '@types/node': 22.19.19 '@types/methods@1.1.4': {} @@ -13161,13 +13196,13 @@ snapshots: '@types/nodemailer@7.0.11': dependencies: - '@types/node': 22.19.15 + '@types/node': 22.19.19 '@types/normalize-package-data@2.4.4': {} '@types/pg-copy-streams@1.2.5': dependencies: - '@types/node': 22.19.15 + '@types/node': 22.19.19 '@types/pg': 8.20.0 '@types/pg@8.20.0': @@ -13207,12 +13242,12 @@ snapshots: '@types/shelljs@0.10.0': dependencies: - '@types/node': 22.19.15 + '@types/node': 22.19.19 fast-glob: 3.3.3 '@types/smtp-server@3.5.13': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.19.19 '@types/nodemailer': 7.0.11 '@types/stack-utils@2.0.3': {} @@ -13221,7 +13256,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 22.19.15 + '@types/node': 22.19.19 form-data: 4.0.5 '@types/supertest@7.2.0': @@ -15559,7 +15594,7 @@ snapshots: '@jest/expect': 30.4.1 '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -15630,6 +15665,38 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@30.4.2(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.4.0 + '@jest/test-sequencer': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.4.2 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-runner: 30.4.2 + jest-util: 30.4.1 + jest-validate: 30.4.1 + parse-json: 5.2.0 + pretty-format: 30.4.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.19 + ts-node: 10.9.2(@types/node@22.19.11)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -15668,7 +15735,7 @@ snapshots: '@jest/environment': 30.4.1 '@jest/fake-timers': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 jest-mock: 30.4.1 jest-util: 30.4.1 jest-validate: 30.4.1 @@ -15678,7 +15745,7 @@ snapshots: jest-haste-map@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -15739,13 +15806,13 @@ snapshots: jest-mock@30.2.0: dependencies: '@jest/types': 30.2.0 - '@types/node': 22.19.15 + '@types/node': 22.19.19 jest-util: 30.2.0 jest-mock@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 jest-util: 30.4.1 jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): @@ -15781,7 +15848,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 @@ -15810,7 +15877,7 @@ snapshots: '@jest/test-result': 30.4.1 '@jest/transform': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 chalk: 4.1.2 cjs-module-lexer: 2.2.0 collect-v8-coverage: 1.0.3 @@ -15857,7 +15924,7 @@ snapshots: jest-util@30.2.0: dependencies: '@jest/types': 30.2.0 - '@types/node': 22.19.15 + '@types/node': 22.19.19 chalk: 4.1.2 ci-info: 4.3.1 graceful-fs: 4.2.11 @@ -15866,7 +15933,7 @@ snapshots: jest-util@30.4.1: dependencies: '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -15885,7 +15952,7 @@ snapshots: dependencies: '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 - '@types/node': 22.19.11 + '@types/node': 22.19.19 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -15894,7 +15961,7 @@ snapshots: jest-worker@30.4.1: dependencies: - '@types/node': 22.19.11 + '@types/node': 22.19.19 '@ungap/structured-clone': 1.3.1 jest-util: 30.4.1 merge-stream: 2.0.0 @@ -17214,7 +17281,7 @@ snapshots: parse5@3.0.3: dependencies: - '@types/node': 22.19.15 + '@types/node': 22.19.19 parse5@7.3.0: dependencies: