Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions apps/backend/src/app/api/deployments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -64,6 +114,12 @@ deploymentRouter.register('GET', {
deployedAt: d.deployed_at,
deploymentUrl: d.deployment_url,
})),
pagination: {
nextCursor: hasMore ? nextCursor : null,
prevCursor,
hasMore,
limit,
},
});
},
});
Expand Down
98 changes: 98 additions & 0 deletions apps/backend/src/services/health-monitor.service.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions apps/backend/src/services/rollout-strategy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, FlagDecisionCacheEntry>();

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';
Expand All @@ -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;
}
Expand Down
105 changes: 105 additions & 0 deletions packages/stellar/src/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@ export type InvokeContractResult<T = SorobanRpc.Api.SimulateTransactionResponse>
| { 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<string, AbiVersionInfo[]> = {
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',
Expand Down Expand Up @@ -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,
};
}