diff --git a/apps/backend/src/app/api/deployments/route.ts b/apps/backend/src/app/api/deployments/route.ts index 3bdc1424..e556a685 100644 --- a/apps/backend/src/app/api/deployments/route.ts +++ b/apps/backend/src/app/api/deployments/route.ts @@ -9,6 +9,20 @@ import { validateStellarEndpoints, } from '@/lib/customization/validate'; +function encodeCursor(createdAt: string, id: string): string { + return Buffer.from(`${createdAt}:${id}`).toString('base64'); +} + +function decodeCursor(cursor: string): { createdAt: string; id: string } | null { + try { + const decoded = Buffer.from(cursor, 'base64').toString('utf-8'); + const [createdAt, id] = decoded.split(':'); + return createdAt && id ? { createdAt, id } : null; + } catch { + return null; + } +} + type RequestBody = { templateId: string; customizationConfig?: unknown; @@ -39,12 +53,38 @@ const deploymentRouter = new ApiVersionRouter({ // GET /api/deployments — list user's deployments (v1) deploymentRouter.register('GET', { supportedVersions: ['v1'], - handler: async (_req: NextRequest, { supabase, user }: any) => { - const { data: deployments, error } = await supabase + handler: async (req: NextRequest, { supabase, user }: any) => { + const url = new URL(req.url); + const cursor = url.searchParams.get('cursor'); + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20'), 100); + const direction = url.searchParams.get('direction') ?? 'next'; + + let query = supabase .from('deployments') .select('id, name, status, template_id, created_at, updated_at, deployed_at, deployment_url') .eq('user_id', user.id) - .order('created_at', { ascending: false }); + .order('created_at', { ascending: false }) + .limit(limit + 1); + + if (cursor) { + const decoded = decodeCursor(cursor); + if (!decoded) { + return NextResponse.json( + { error: 'Invalid cursor' }, + { status: 400 }, + ); + } + + if (direction === 'prev') { + query = query.gt('created_at', decoded.createdAt) + .or(`created_at.eq.${decoded.createdAt},id.gt.${decoded.id}`); + } else { + query = query.lt('created_at', decoded.createdAt) + .or(`created_at.eq.${decoded.createdAt},id.lt.${decoded.id}`); + } + } + + const { data: deployments, error } = await query; if (error) { return NextResponse.json( @@ -53,8 +93,18 @@ deploymentRouter.register('GET', { ); } + const items = deployments ?? []; + const hasMore = items.length > limit; + const pageItems = hasMore ? items.slice(0, limit) : items; + + const nextCursor = pageItems.length > 0 + ? encodeCursor(pageItems[pageItems.length - 1].created_at, pageItems[pageItems.length - 1].id) + : null; + + const prevCursor = cursor ?? null; + return NextResponse.json({ - deployments: (deployments ?? []).map((d: any) => ({ + deployments: pageItems.map((d: any) => ({ id: d.id, name: d.name, status: d.status, @@ -64,6 +114,12 @@ deploymentRouter.register('GET', { deployedAt: d.deployed_at, deploymentUrl: d.deployment_url, })), + pagination: { + nextCursor: hasMore ? nextCursor : null, + prevCursor, + hasMore, + limit, + }, }); }, }); diff --git a/apps/backend/src/services/health-monitor.service.ts b/apps/backend/src/services/health-monitor.service.ts index 5a0f2ee9..b87397b7 100644 --- a/apps/backend/src/services/health-monitor.service.ts +++ b/apps/backend/src/services/health-monitor.service.ts @@ -1,6 +1,33 @@ import { createClient } from '@/lib/supabase/server'; import { analyticsService } from './analytics.service'; +export interface PoolMetrics { + activeConnections: number; + idleConnections: number; + waitQueueLength: number; + totalConnections: number; + utilizationPercent: number; + averageWaitTimeMs: number; +} + +interface PoolMetricsInternal { + activeConnections: number; + idleConnections: number; + waitQueueLength: number; + waitTimes: number[]; + lastSampled: number; +} + +const POOL_ALERT_THRESHOLD = 0.8; +const POOL_METRICS_WINDOW_MS = 60_000; +const poolMetrics: PoolMetricsInternal = { + activeConnections: 0, + idleConnections: 0, + waitQueueLength: 0, + waitTimes: [], + lastSampled: Date.now(), +}; + export class HealthMonitorService { /** * Check deployment health @@ -130,6 +157,77 @@ export class HealthMonitorService { } } } + + /** + * Record connection pool metrics + */ + recordPoolMetrics( + activeConnections: number, + idleConnections: number, + waitQueueLength: number, + waitTimeMs: number + ): void { + poolMetrics.activeConnections = activeConnections; + poolMetrics.idleConnections = idleConnections; + poolMetrics.waitQueueLength = waitQueueLength; + poolMetrics.waitTimes.push(waitTimeMs); + poolMetrics.lastSampled = Date.now(); + + if (poolMetrics.waitTimes.length > 1000) { + poolMetrics.waitTimes = poolMetrics.waitTimes.slice(-1000); + } + } + + /** + * Get current pool health metrics + */ + getPoolMetrics(): PoolMetrics { + const totalConnections = poolMetrics.activeConnections + poolMetrics.idleConnections; + const utilizationPercent = totalConnections > 0 + ? (poolMetrics.activeConnections / totalConnections) * 100 + : 0; + + const averageWaitTimeMs = poolMetrics.waitTimes.length > 0 + ? poolMetrics.waitTimes.reduce((a, b) => a + b, 0) / poolMetrics.waitTimes.length + : 0; + + return { + activeConnections: poolMetrics.activeConnections, + idleConnections: poolMetrics.idleConnections, + waitQueueLength: poolMetrics.waitQueueLength, + totalConnections, + utilizationPercent, + averageWaitTimeMs, + }; + } + + /** + * Check if pool health is degraded + */ + isPoolHealthDegraded(): boolean { + const metrics = this.getPoolMetrics(); + return metrics.utilizationPercent >= POOL_ALERT_THRESHOLD * 100 || + metrics.waitQueueLength > 10 || + metrics.averageWaitTimeMs > 1000; + } + + /** + * Get complete health status including pool metrics + */ + async getSystemHealth(): Promise<{ + status: 'healthy' | 'degraded' | 'unhealthy'; + timestamp: number; + poolMetrics: PoolMetrics; + }> { + const metrics = this.getPoolMetrics(); + const isDegraded = this.isPoolHealthDegraded(); + + return { + status: isDegraded ? 'degraded' : 'healthy', + timestamp: Date.now(), + poolMetrics: metrics, + }; + } } // Export singleton instance diff --git a/apps/backend/src/services/rollout-strategy.service.ts b/apps/backend/src/services/rollout-strategy.service.ts index 54a32712..5b5b1827 100644 --- a/apps/backend/src/services/rollout-strategy.service.ts +++ b/apps/backend/src/services/rollout-strategy.service.ts @@ -11,6 +11,31 @@ export const ROLLBACK_ERROR_RATE_THRESHOLD = 0.05; export const ROLLBACK_LATENCY_THRESHOLD_MS = 2_000; export const DEFAULT_CANARY_STEPS = [5, 25, 50] as const; +interface FlagDecisionCacheEntry { + decision: boolean; + timestamp: number; +} + +const FLAG_CACHE_TTL_MS = 5_000; +const MAX_FLAG_CACHE_ENTRIES = 10_000; +const flagEvaluationCache = new Map(); + +function buildFlagCacheKey(userId: string, flagKey: string): string { + return `${userId}:${flagKey}`; +} + +function invalidateFlagCache(flagKey?: string): void { + if (flagKey) { + for (const [key] of flagEvaluationCache) { + if (key.endsWith(`:${flagKey}`)) { + flagEvaluationCache.delete(key); + } + } + } else { + flagEvaluationCache.clear(); + } +} + export class RolloutEngine { private _canaryPercent = 0; private _status: RolloutStatus = 'pending'; @@ -21,6 +46,30 @@ export class RolloutEngine { private readonly candidate: DeploymentVersion, ) {} + evaluateFlagWithCache(userId: string, flagKey: string, evaluator: () => boolean): boolean { + const cacheKey = buildFlagCacheKey(userId, flagKey); + const now = Date.now(); + + const cached = flagEvaluationCache.get(cacheKey); + if (cached && now - cached.timestamp < FLAG_CACHE_TTL_MS) { + return cached.decision; + } + + const decision = evaluator(); + + if (flagEvaluationCache.size >= MAX_FLAG_CACHE_ENTRIES) { + const firstKey = flagEvaluationCache.keys().next().value; + if (firstKey) flagEvaluationCache.delete(firstKey); + } + + flagEvaluationCache.set(cacheKey, { decision, timestamp: now }); + return decision; + } + + clearFlagCache(flagKey?: string): void { + invalidateFlagCache(flagKey); + } + get status(): RolloutStatus { return this._status; } diff --git a/packages/stellar/src/soroban.ts b/packages/stellar/src/soroban.ts index 94eb83cf..90fa3e85 100644 --- a/packages/stellar/src/soroban.ts +++ b/packages/stellar/src/soroban.ts @@ -13,6 +13,30 @@ export type InvokeContractResult | { ok: true; result: T } | { ok: false; error: AppError }; +export interface AbiVersionInfo { + major: number; + minor: number; + patch: number; +} + +export interface AbiCompatibilityResult { + compatible: boolean; + contractAbi: AbiVersionInfo; + networkSupportedVersions: AbiVersionInfo[]; + error?: string; +} + +export const SUPPORTED_ABI_VERSIONS: Record = { + mainnet: [ + { major: 20, minor: 0, patch: 0 }, + { major: 21, minor: 0, patch: 0 }, + ], + testnet: [ + { major: 20, minor: 0, patch: 0 }, + { major: 21, minor: 0, patch: 0 }, + ], +}; + const SOROBAN_RPC_URLS = { mainnet: 'https://soroban-mainnet.stellar.org', testnet: 'https://soroban-testnet.stellar.org', @@ -262,3 +286,84 @@ export async function invokeContractMethod( }; } } + +function parseAbiVersion(versionString: string): AbiVersionInfo | null { + const match = versionString.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!match) return null; + return { + major: parseInt(match[1], 10), + minor: parseInt(match[2], 10), + patch: parseInt(match[3], 10), + }; +} + +function detectContractAbiVersion(contractSpec: any): AbiVersionInfo | null { + if (!contractSpec) return null; + + if (typeof contractSpec.version === 'string') { + return parseAbiVersion(contractSpec.version); + } + + if (typeof contractSpec.contractAbiVersion === 'string') { + return parseAbiVersion(contractSpec.contractAbiVersion); + } + + if (contractSpec.abiVersion && typeof contractSpec.abiVersion === 'object') { + return { + major: contractSpec.abiVersion.major ?? 0, + minor: contractSpec.abiVersion.minor ?? 0, + patch: contractSpec.abiVersion.patch ?? 0, + }; + } + + return null; +} + +function isAbiVersionCompatible(contractAbi: AbiVersionInfo, supported: AbiVersionInfo[]): boolean { + return supported.some( + (v) => v.major === contractAbi.major && v.minor === contractAbi.minor + ); +} + +/** + * Validates that a contract ABI version is compatible with the target network. + * @param contractSpec - The contract specification object + * @param network - The network name ('mainnet' or 'testnet') + * @returns AbiCompatibilityResult indicating compatibility + */ +export function validateContractAbiVersion( + contractSpec: any, + network: string = config.stellar.network +): AbiCompatibilityResult { + const detectedAbi = detectContractAbiVersion(contractSpec); + const supportedVersions = SUPPORTED_ABI_VERSIONS[network] ?? []; + + if (!detectedAbi) { + return { + compatible: false, + contractAbi: { major: 0, minor: 0, patch: 0 }, + networkSupportedVersions: supportedVersions, + error: 'Unable to detect contract ABI version', + }; + } + + const compatible = isAbiVersionCompatible(detectedAbi, supportedVersions); + + if (!compatible) { + const supportedStr = supportedVersions + .map((v) => `${v.major}.${v.minor}.${v.patch}`) + .join(', '); + return { + compatible: false, + contractAbi: detectedAbi, + networkSupportedVersions: supportedVersions, + error: `Contract ABI version ${detectedAbi.major}.${detectedAbi.minor}.${detectedAbi.patch} is not supported on ${network}. Supported versions: ${supportedStr}`, + }; + } + + return { + compatible: true, + contractAbi: detectedAbi, + networkSupportedVersions: supportedVersions, + }; +}