refactor: code review 기반 품질 개선
- HonoVariables 타입 중앙화 (types.ts로 추출, 5개 파일 중복 제거) - 6시간 pricing update cron 핸들러 추가 (syncPricingOnly 메서드) - Response.json() → c.json() 패턴 통일 (Hono 표준) - SORT_FIELD_MAP 중앙화 (constants.ts, 12개 필드 지원) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
12
src/app.ts
12
src/app.ts
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import type { Env } from './types';
|
import type { Env, HonoVariables } from './types';
|
||||||
import { CORS, HTTP_STATUS } from './constants';
|
import { CORS, HTTP_STATUS } from './constants';
|
||||||
import { createLogger } from './utils/logger';
|
import { createLogger } from './utils/logger';
|
||||||
import {
|
import {
|
||||||
@@ -21,20 +21,14 @@ import { handleSync } from './routes/sync';
|
|||||||
|
|
||||||
const logger = createLogger('[App]');
|
const logger = createLogger('[App]');
|
||||||
|
|
||||||
// Context variables type
|
|
||||||
type Variables = {
|
|
||||||
requestId: string;
|
|
||||||
authenticated?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create Hono app with type-safe bindings
|
// Create Hono app with type-safe bindings
|
||||||
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
|
const app = new Hono<{ Bindings: Env; Variables: HonoVariables }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get CORS origin for request
|
* Get CORS origin for request
|
||||||
* Reused from original index.ts logic
|
* Reused from original index.ts logic
|
||||||
*/
|
*/
|
||||||
function getCorsOrigin(c: Context<{ Bindings: Env; Variables: Variables }>): string {
|
function getCorsOrigin(c: Context<{ Bindings: Env; Variables: HonoVariables }>): string {
|
||||||
const origin = c.req.header('Origin');
|
const origin = c.req.header('Origin');
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
|
|
||||||
|
|||||||
@@ -129,22 +129,32 @@ export const TABLES = {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valid sort fields for instance queries
|
* Mapping of user-facing sort field names to database column names
|
||||||
|
*
|
||||||
|
* This is the single source of truth for sort field validation and mapping.
|
||||||
|
* Query aliases: it=instance_types, pr=pricing, p=providers, r=regions
|
||||||
*/
|
*/
|
||||||
export const VALID_SORT_FIELDS = [
|
export const SORT_FIELD_MAP: Record<string, string> = {
|
||||||
'price',
|
price: 'pr.hourly_price',
|
||||||
'hourly_price',
|
hourly_price: 'pr.hourly_price',
|
||||||
'monthly_price',
|
monthly_price: 'pr.monthly_price',
|
||||||
'vcpu',
|
vcpu: 'it.vcpu',
|
||||||
'memory_mb',
|
memory: 'it.memory_mb',
|
||||||
'memory_gb',
|
memory_mb: 'it.memory_mb',
|
||||||
'storage_gb',
|
memory_gb: 'it.memory_mb', // Note: memory_gb is converted to memory_mb at query level
|
||||||
'instance_name',
|
storage_gb: 'it.storage_gb',
|
||||||
'provider',
|
name: 'it.instance_name',
|
||||||
'region',
|
instance_name: 'it.instance_name',
|
||||||
] as const;
|
provider: 'p.name',
|
||||||
|
region: 'r.region_code',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type ValidSortField = typeof VALID_SORT_FIELDS[number];
|
/**
|
||||||
|
* Valid sort fields for instance queries (derived from SORT_FIELD_MAP)
|
||||||
|
*/
|
||||||
|
export const VALID_SORT_FIELDS = Object.keys(SORT_FIELD_MAP) as ReadonlyArray<string>;
|
||||||
|
|
||||||
|
export type ValidSortField = keyof typeof SORT_FIELD_MAP;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valid sort orders
|
* Valid sort orders
|
||||||
|
|||||||
79
src/index.ts
79
src/index.ts
@@ -20,7 +20,7 @@ export default {
|
|||||||
*
|
*
|
||||||
* Cron Schedules:
|
* Cron Schedules:
|
||||||
* - 0 0 * * * : Daily full sync at 00:00 UTC
|
* - 0 0 * * * : Daily full sync at 00:00 UTC
|
||||||
* - 0 star-slash-6 * * * : Pricing update every 6 hours
|
* - 0 star/6 * * * : Pricing update every 6 hours
|
||||||
*/
|
*/
|
||||||
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
const logger = createLogger('[Cron]', env);
|
const logger = createLogger('[Cron]', env);
|
||||||
@@ -47,17 +47,18 @@ export default {
|
|||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
try {
|
try {
|
||||||
logger.info('Starting sync attempt', {
|
logger.info('Starting full sync attempt', {
|
||||||
attempt_number: attempt,
|
attempt_number: attempt,
|
||||||
max_retries: MAX_RETRIES
|
max_retries: MAX_RETRIES
|
||||||
});
|
});
|
||||||
|
|
||||||
const report = await orchestrator.syncAll(['linode', 'vultr', 'aws']);
|
const report = await orchestrator.syncAll(['linode', 'vultr', 'aws']);
|
||||||
|
|
||||||
logger.info('Daily sync complete', {
|
logger.info('Daily full sync complete', {
|
||||||
attempt_number: attempt,
|
attempt_number: attempt,
|
||||||
total_regions: report.summary.total_regions,
|
total_regions: report.summary.total_regions,
|
||||||
total_instances: report.summary.total_instances,
|
total_instances: report.summary.total_instances,
|
||||||
|
total_pricing: report.summary.total_pricing,
|
||||||
successful_providers: report.summary.successful_providers,
|
successful_providers: report.summary.successful_providers,
|
||||||
failed_providers: report.summary.failed_providers,
|
failed_providers: report.summary.failed_providers,
|
||||||
duration_ms: report.total_duration_ms
|
duration_ms: report.total_duration_ms
|
||||||
@@ -111,5 +112,77 @@ export default {
|
|||||||
|
|
||||||
ctx.waitUntil(executeSyncWithRetry());
|
ctx.waitUntil(executeSyncWithRetry());
|
||||||
}
|
}
|
||||||
|
// Pricing update every 6 hours
|
||||||
|
else if (cron === '0 */6 * * *') {
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
const executePricingSyncWithRetry = async (): Promise<void> => {
|
||||||
|
const orchestrator = new SyncOrchestrator(env.DB, env);
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
logger.info('Starting pricing sync attempt', {
|
||||||
|
attempt_number: attempt,
|
||||||
|
max_retries: MAX_RETRIES
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await orchestrator.syncAllPricingOnly(['linode', 'vultr', 'aws']);
|
||||||
|
|
||||||
|
logger.info('Pricing sync complete', {
|
||||||
|
attempt_number: attempt,
|
||||||
|
total_pricing: report.summary.total_pricing,
|
||||||
|
successful_providers: report.summary.successful_providers,
|
||||||
|
failed_providers: report.summary.failed_providers,
|
||||||
|
duration_ms: report.total_duration_ms
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alert on partial failures
|
||||||
|
if (report.summary.failed_providers > 0) {
|
||||||
|
const failedProviders = report.providers
|
||||||
|
.filter(p => !p.success)
|
||||||
|
.map(p => p.provider);
|
||||||
|
|
||||||
|
logger.warn('Some providers failed during pricing sync', {
|
||||||
|
failed_count: report.summary.failed_providers,
|
||||||
|
failed_providers: failedProviders,
|
||||||
|
errors: report.providers
|
||||||
|
.filter(p => !p.success)
|
||||||
|
.map(p => ({ provider: p.provider, error: p.error }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - exit retry loop
|
||||||
|
return;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const willRetry = attempt < MAX_RETRIES;
|
||||||
|
const retryDelayMs = willRetry ? Math.min(Math.pow(2, attempt - 1) * 1000, 10000) : 0;
|
||||||
|
|
||||||
|
logger.error('Pricing sync attempt failed', {
|
||||||
|
attempt_number: attempt,
|
||||||
|
max_retries: MAX_RETRIES,
|
||||||
|
will_retry: willRetry,
|
||||||
|
retry_delay_ms: retryDelayMs,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (willRetry) {
|
||||||
|
// Wait before retry with exponential backoff
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
||||||
|
} else {
|
||||||
|
// Final failure - re-throw to make cron failure visible
|
||||||
|
logger.error('Pricing sync failed after all retries', {
|
||||||
|
total_attempts: MAX_RETRIES,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.waitUntil(executePricingSyncWithRetry());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context, Next } from 'hono';
|
import type { Context, Next } from 'hono';
|
||||||
import type { Env } from '../types';
|
import type { Env, HonoVariables } from '../types';
|
||||||
import {
|
import {
|
||||||
authenticateRequest,
|
authenticateRequest,
|
||||||
verifyApiKey,
|
verifyApiKey,
|
||||||
@@ -16,18 +16,12 @@ import { createLogger } from '../utils/logger';
|
|||||||
|
|
||||||
const logger = createLogger('[Middleware]');
|
const logger = createLogger('[Middleware]');
|
||||||
|
|
||||||
// Context variables type
|
|
||||||
type Variables = {
|
|
||||||
requestId: string;
|
|
||||||
authenticated?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request ID middleware
|
* Request ID middleware
|
||||||
* Adds unique request ID to context for tracing
|
* Adds unique request ID to context for tracing
|
||||||
*/
|
*/
|
||||||
export async function requestIdMiddleware(
|
export async function requestIdMiddleware(
|
||||||
c: Context<{ Bindings: Env; Variables: Variables }>,
|
c: Context<{ Bindings: Env; Variables: HonoVariables }>,
|
||||||
next: Next
|
next: Next
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Use CF-Ray if available, otherwise generate UUID
|
// Use CF-Ray if available, otherwise generate UUID
|
||||||
@@ -47,7 +41,7 @@ export async function requestIdMiddleware(
|
|||||||
* Validates X-API-Key header using existing auth logic
|
* Validates X-API-Key header using existing auth logic
|
||||||
*/
|
*/
|
||||||
export async function authMiddleware(
|
export async function authMiddleware(
|
||||||
c: Context<{ Bindings: Env; Variables: Variables }>,
|
c: Context<{ Bindings: Env; Variables: HonoVariables }>,
|
||||||
next: Next
|
next: Next
|
||||||
): Promise<Response | void> {
|
): Promise<Response | void> {
|
||||||
const request = c.req.raw;
|
const request = c.req.raw;
|
||||||
@@ -71,7 +65,7 @@ export async function authMiddleware(
|
|||||||
* Applies rate limits based on endpoint using existing rate limit logic
|
* Applies rate limits based on endpoint using existing rate limit logic
|
||||||
*/
|
*/
|
||||||
export async function rateLimitMiddleware(
|
export async function rateLimitMiddleware(
|
||||||
c: Context<{ Bindings: Env; Variables: Variables }>,
|
c: Context<{ Bindings: Env; Variables: HonoVariables }>,
|
||||||
next: Next
|
next: Next
|
||||||
): Promise<Response | void> {
|
): Promise<Response | void> {
|
||||||
const request = c.req.raw;
|
const request = c.req.raw;
|
||||||
@@ -97,7 +91,7 @@ export async function rateLimitMiddleware(
|
|||||||
* Checks if API key is provided and valid, stores result in context
|
* Checks if API key is provided and valid, stores result in context
|
||||||
*/
|
*/
|
||||||
export async function optionalAuthMiddleware(
|
export async function optionalAuthMiddleware(
|
||||||
c: Context<{ Bindings: Env; Variables: Variables }>,
|
c: Context<{ Bindings: Env; Variables: HonoVariables }>,
|
||||||
next: Next
|
next: Next
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const apiKey = c.req.header('X-API-Key');
|
const apiKey = c.req.header('X-API-Key');
|
||||||
|
|||||||
@@ -4,18 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { Env } from '../types';
|
import { Env, HonoVariables } from '../types';
|
||||||
import { HTTP_STATUS } from '../constants';
|
import { HTTP_STATUS } from '../constants';
|
||||||
import { createLogger } from '../utils/logger';
|
import { createLogger } from '../utils/logger';
|
||||||
|
|
||||||
const logger = createLogger('[Health]');
|
const logger = createLogger('[Health]');
|
||||||
|
|
||||||
// Context variables type
|
|
||||||
type Variables = {
|
|
||||||
requestId: string;
|
|
||||||
authenticated?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component health status
|
* Component health status
|
||||||
*/
|
*/
|
||||||
@@ -169,7 +163,7 @@ function sanitizeError(error: string): string {
|
|||||||
* @param c - Hono context
|
* @param c - Hono context
|
||||||
*/
|
*/
|
||||||
export async function handleHealth(
|
export async function handleHealth(
|
||||||
c: Context<{ Bindings: Env; Variables: Variables }>
|
c: Context<{ Bindings: Env; Variables: HonoVariables }>
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const authenticated = c.get('authenticated') ?? false;
|
const authenticated = c.get('authenticated') ?? false;
|
||||||
@@ -186,7 +180,7 @@ export async function handleHealth(
|
|||||||
status: 'unhealthy',
|
status: 'unhealthy',
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
return Response.json(publicResponse, { status: HTTP_STATUS.SERVICE_UNAVAILABLE });
|
return c.json(publicResponse, HTTP_STATUS.SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detailed response: full information with sanitized errors
|
// Detailed response: full information with sanitized errors
|
||||||
@@ -208,7 +202,7 @@ export async function handleHealth(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return Response.json(detailedResponse, { status: HTTP_STATUS.SERVICE_UNAVAILABLE });
|
return c.json(detailedResponse, HTTP_STATUS.SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all providers with aggregated counts in a single query
|
// Get all providers with aggregated counts in a single query
|
||||||
@@ -296,7 +290,7 @@ export async function handleHealth(
|
|||||||
status: overallStatus,
|
status: overallStatus,
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
return Response.json(publicResponse, { status: statusCode });
|
return c.json(publicResponse, statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detailed response: full information
|
// Detailed response: full information
|
||||||
@@ -319,7 +313,7 @@ export async function handleHealth(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return Response.json(detailedResponse, { status: statusCode });
|
return c.json(detailedResponse, statusCode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Health check failed', { error });
|
logger.error('Health check failed', { error });
|
||||||
|
|
||||||
@@ -329,7 +323,7 @@ export async function handleHealth(
|
|||||||
status: 'unhealthy',
|
status: 'unhealthy',
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
return Response.json(publicResponse, { status: HTTP_STATUS.SERVICE_UNAVAILABLE });
|
return c.json(publicResponse, HTTP_STATUS.SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detailed response: sanitized error information
|
// Detailed response: sanitized error information
|
||||||
@@ -352,6 +346,6 @@ export async function handleHealth(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return Response.json(detailedResponse, { status: HTTP_STATUS.SERVICE_UNAVAILABLE });
|
return c.json(detailedResponse, HTTP_STATUS.SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import type { Env, InstanceQueryParams } from '../types';
|
import type { Env, HonoVariables, InstanceQueryParams } from '../types';
|
||||||
import { QueryService } from '../services/query';
|
import { QueryService } from '../services/query';
|
||||||
import { getGlobalCacheService } from '../services/cache';
|
import { getGlobalCacheService } from '../services/cache';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// Context variables type
|
|
||||||
type Variables = {
|
|
||||||
requestId: string;
|
|
||||||
authenticated?: boolean;
|
|
||||||
};
|
|
||||||
import {
|
import {
|
||||||
SUPPORTED_PROVIDERS,
|
SUPPORTED_PROVIDERS,
|
||||||
type SupportedProvider,
|
type SupportedProvider,
|
||||||
@@ -325,7 +319,7 @@ function parseQueryParams(url: URL): {
|
|||||||
* GET /instances?provider=linode&min_vcpu=2&max_price=20&sort_by=price&order=asc&limit=50
|
* GET /instances?provider=linode&min_vcpu=2&max_price=20&sort_by=price&order=asc&limit=50
|
||||||
*/
|
*/
|
||||||
export async function handleInstances(
|
export async function handleInstances(
|
||||||
c: Context<{ Bindings: Env; Variables: Variables }>
|
c: Context<{ Bindings: Env; Variables: HonoVariables }>
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
@@ -339,12 +333,12 @@ export async function handleInstances(
|
|||||||
// Handle validation errors
|
// Handle validation errors
|
||||||
if (parseResult.error) {
|
if (parseResult.error) {
|
||||||
logger.error('[Instances] Validation error', parseResult.error);
|
logger.error('[Instances] Validation error', parseResult.error);
|
||||||
return Response.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: parseResult.error,
|
error: parseResult.error,
|
||||||
},
|
},
|
||||||
{ status: HTTP_STATUS.BAD_REQUEST }
|
HTTP_STATUS.BAD_REQUEST
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +377,7 @@ export async function handleInstances(
|
|||||||
age: cached.cache_age_seconds,
|
age: cached.cache_age_seconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -396,11 +390,9 @@ export async function handleInstances(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
HTTP_STATUS.OK,
|
||||||
{
|
{
|
||||||
status: HTTP_STATUS.OK,
|
'Cache-Control': `public, max-age=${CACHE_TTL.INSTANCES}`,
|
||||||
headers: {
|
|
||||||
'Cache-Control': `public, max-age=${CACHE_TTL.INSTANCES}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -463,23 +455,21 @@ export async function handleInstances(
|
|||||||
error instanceof Error ? { message: error.message } : { error: String(error) });
|
error instanceof Error ? { message: error.message } : { error: String(error) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
data: responseData,
|
data: responseData,
|
||||||
},
|
},
|
||||||
|
HTTP_STATUS.OK,
|
||||||
{
|
{
|
||||||
status: HTTP_STATUS.OK,
|
'Cache-Control': `public, max-age=${CACHE_TTL.INSTANCES}`,
|
||||||
headers: {
|
|
||||||
'Cache-Control': `public, max-age=${CACHE_TTL.INSTANCES}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Instances] Unexpected error', { error });
|
logger.error('[Instances] Unexpected error', { error });
|
||||||
|
|
||||||
return Response.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
@@ -488,7 +478,7 @@ export async function handleInstances(
|
|||||||
request_id: crypto.randomUUID(),
|
request_id: crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ status: HTTP_STATUS.INTERNAL_ERROR }
|
HTTP_STATUS.INTERNAL_ERROR
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import type { Env } from '../types';
|
import type { Env, HonoVariables } from '../types';
|
||||||
import { SyncOrchestrator } from '../services/sync';
|
import { SyncOrchestrator } from '../services/sync';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { SUPPORTED_PROVIDERS, HTTP_STATUS } from '../constants';
|
import { SUPPORTED_PROVIDERS, HTTP_STATUS } from '../constants';
|
||||||
import { parseJsonBody, validateProviders, createErrorResponse } from '../utils/validation';
|
import { parseJsonBody, validateProviders, createErrorResponse } from '../utils/validation';
|
||||||
|
|
||||||
// Context variables type
|
|
||||||
type Variables = {
|
|
||||||
requestId: string;
|
|
||||||
authenticated?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request body interface for sync endpoint
|
* Request body interface for sync endpoint
|
||||||
*/
|
*/
|
||||||
@@ -41,7 +35,7 @@ interface SyncRequestBody {
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export async function handleSync(
|
export async function handleSync(
|
||||||
c: Context<{ Bindings: Env; Variables: Variables }>
|
c: Context<{ Bindings: Env; Variables: HonoVariables }>
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
@@ -54,9 +48,9 @@ export async function handleSync(
|
|||||||
if (contentLength) {
|
if (contentLength) {
|
||||||
const bodySize = parseInt(contentLength, 10);
|
const bodySize = parseInt(contentLength, 10);
|
||||||
if (isNaN(bodySize) || bodySize > 10240) { // 10KB limit for sync
|
if (isNaN(bodySize) || bodySize > 10240) { // 10KB limit for sync
|
||||||
return Response.json(
|
return c.json(
|
||||||
{ success: false, error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body too large' } },
|
{ success: false, error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body too large' } },
|
||||||
{ status: 413 }
|
413
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +105,7 @@ export async function handleSync(
|
|||||||
summary: syncReport.summary
|
summary: syncReport.summary
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: syncReport.success,
|
success: syncReport.success,
|
||||||
data: {
|
data: {
|
||||||
@@ -119,7 +113,7 @@ export async function handleSync(
|
|||||||
...syncReport
|
...syncReport
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ status: HTTP_STATUS.OK }
|
HTTP_STATUS.OK
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -128,7 +122,7 @@ export async function handleSync(
|
|||||||
const completedAt = new Date().toISOString();
|
const completedAt = new Date().toISOString();
|
||||||
const totalDuration = Date.now() - startTime;
|
const totalDuration = Date.now() - startTime;
|
||||||
|
|
||||||
return Response.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
@@ -142,7 +136,7 @@ export async function handleSync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ status: HTTP_STATUS.INTERNAL_ERROR }
|
HTTP_STATUS.INTERNAL_ERROR
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '../utils/logger';
|
import { createLogger } from '../utils/logger';
|
||||||
|
import { SORT_FIELD_MAP } from '../constants';
|
||||||
import type {
|
import type {
|
||||||
Env,
|
Env,
|
||||||
InstanceQueryParams,
|
InstanceQueryParams,
|
||||||
@@ -299,19 +300,8 @@ export class QueryService {
|
|||||||
// Validate sort order at service level (defense in depth)
|
// Validate sort order at service level (defense in depth)
|
||||||
const validatedSortOrder = sortOrder?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
const validatedSortOrder = sortOrder?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
// Map sort fields to actual column names
|
// Map sort fields to actual column names (imported from constants.ts)
|
||||||
const sortFieldMap: Record<string, string> = {
|
const sortColumn = SORT_FIELD_MAP[sortBy] ?? 'pr.hourly_price';
|
||||||
price: 'pr.hourly_price',
|
|
||||||
hourly_price: 'pr.hourly_price',
|
|
||||||
monthly_price: 'pr.monthly_price',
|
|
||||||
vcpu: 'it.vcpu',
|
|
||||||
memory: 'it.memory_mb',
|
|
||||||
memory_mb: 'it.memory_mb',
|
|
||||||
name: 'it.instance_name',
|
|
||||||
instance_name: 'it.instance_name',
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortColumn = sortFieldMap[sortBy] ?? 'pr.hourly_price';
|
|
||||||
|
|
||||||
// Handle NULL values in pricing columns (NULL values go last)
|
// Handle NULL values in pricing columns (NULL values go last)
|
||||||
if (sortColumn.startsWith('pr.')) {
|
if (sortColumn.startsWith('pr.')) {
|
||||||
|
|||||||
@@ -438,6 +438,296 @@ export class SyncOrchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronize pricing data only (no regions/instances update)
|
||||||
|
*
|
||||||
|
* Lightweight sync operation that only updates pricing data from provider APIs.
|
||||||
|
* Skips region and instance type synchronization.
|
||||||
|
*
|
||||||
|
* @param provider - Provider name (linode, vultr, aws)
|
||||||
|
* @returns Sync result with pricing statistics
|
||||||
|
*/
|
||||||
|
async syncPricingOnly(provider: string): Promise<ProviderSyncResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let stage = SyncStage.INIT;
|
||||||
|
|
||||||
|
this.logger.info('Starting pricing-only sync for provider', { provider });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Stage 1: Initialize - Fetch provider record
|
||||||
|
stage = SyncStage.INIT;
|
||||||
|
const providerRecord = await this.repos.providers.findByName(provider);
|
||||||
|
if (!providerRecord) {
|
||||||
|
throw new Error(`Provider not found in database: ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update provider status to syncing
|
||||||
|
await this.repos.providers.updateSyncStatus(provider, 'syncing');
|
||||||
|
this.logger.info(`${provider} → ${stage} (pricing only)`);
|
||||||
|
|
||||||
|
// Stage 2: Initialize connector and authenticate
|
||||||
|
const connector = await this.createConnector(provider, providerRecord.id);
|
||||||
|
await withTimeout(connector.authenticate(), 10000, `${provider} authentication`);
|
||||||
|
this.logger.info(`${provider} → initialized (pricing only)`);
|
||||||
|
|
||||||
|
// Fetch existing instance and region IDs from database
|
||||||
|
const batchQueries = [
|
||||||
|
this.repos.db.prepare('SELECT id, region_code FROM regions WHERE provider_id = ?').bind(providerRecord.id),
|
||||||
|
this.repos.db.prepare('SELECT id, instance_id FROM instance_types WHERE provider_id = ?').bind(providerRecord.id),
|
||||||
|
this.repos.db.prepare('SELECT id, instance_id FROM gpu_instances WHERE provider_id = ?').bind(providerRecord.id),
|
||||||
|
this.repos.db.prepare('SELECT id, instance_id FROM g8_instances WHERE provider_id = ?').bind(providerRecord.id),
|
||||||
|
this.repos.db.prepare('SELECT id, instance_id FROM vpu_instances WHERE provider_id = ?').bind(providerRecord.id)
|
||||||
|
];
|
||||||
|
|
||||||
|
const [dbRegionsResult, dbInstancesResult, dbGpuResult, dbG8Result, dbVpuResult] = await this.repos.db.batch(batchQueries);
|
||||||
|
|
||||||
|
if (!dbRegionsResult.success || !dbInstancesResult.success) {
|
||||||
|
throw new Error('Failed to fetch regions/instances for pricing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and extract region IDs
|
||||||
|
if (!Array.isArray(dbRegionsResult.results)) {
|
||||||
|
throw new Error('Unexpected database result format for regions');
|
||||||
|
}
|
||||||
|
const regionIds = dbRegionsResult.results.map((r: any) => {
|
||||||
|
if (typeof r?.id !== 'number') {
|
||||||
|
throw new Error('Invalid region id in database result');
|
||||||
|
}
|
||||||
|
return r.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate and extract instance type data
|
||||||
|
if (!Array.isArray(dbInstancesResult.results)) {
|
||||||
|
throw new Error('Unexpected database result format for instances');
|
||||||
|
}
|
||||||
|
const dbInstancesData = dbInstancesResult.results.map((i: any) => {
|
||||||
|
if (typeof i?.id !== 'number' || typeof i?.instance_id !== 'string') {
|
||||||
|
throw new Error('Invalid instance data in database result');
|
||||||
|
}
|
||||||
|
return { id: i.id, instance_id: i.instance_id };
|
||||||
|
});
|
||||||
|
const instanceTypeIds = dbInstancesData.map(i => i.id);
|
||||||
|
|
||||||
|
// Create instance mapping
|
||||||
|
const dbInstanceMap = new Map(
|
||||||
|
dbInstancesData.map(i => [i.id, { instance_id: i.instance_id }])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create specialized instance mappings
|
||||||
|
if (!Array.isArray(dbGpuResult.results)) {
|
||||||
|
throw new Error('Unexpected database result format for GPU instances');
|
||||||
|
}
|
||||||
|
const dbGpuMap = new Map(
|
||||||
|
dbGpuResult.results.map((i: any) => {
|
||||||
|
if (typeof i?.id !== 'number' || typeof i?.instance_id !== 'string') {
|
||||||
|
throw new Error('Invalid GPU instance data in database result');
|
||||||
|
}
|
||||||
|
return [i.id, { instance_id: i.instance_id }];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Array.isArray(dbG8Result.results)) {
|
||||||
|
throw new Error('Unexpected database result format for G8 instances');
|
||||||
|
}
|
||||||
|
const dbG8Map = new Map(
|
||||||
|
dbG8Result.results.map((i: any) => {
|
||||||
|
if (typeof i?.id !== 'number' || typeof i?.instance_id !== 'string') {
|
||||||
|
throw new Error('Invalid G8 instance data in database result');
|
||||||
|
}
|
||||||
|
return [i.id, { instance_id: i.instance_id }];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Array.isArray(dbVpuResult.results)) {
|
||||||
|
throw new Error('Unexpected database result format for VPU instances');
|
||||||
|
}
|
||||||
|
const dbVpuMap = new Map(
|
||||||
|
dbVpuResult.results.map((i: any) => {
|
||||||
|
if (typeof i?.id !== 'number' || typeof i?.instance_id !== 'string') {
|
||||||
|
throw new Error('Invalid VPU instance data in database result');
|
||||||
|
}
|
||||||
|
return [i.id, { instance_id: i.instance_id }];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get pricing data
|
||||||
|
stage = SyncStage.PERSIST;
|
||||||
|
const pricingResult = await withTimeout(
|
||||||
|
connector.getPricing(
|
||||||
|
instanceTypeIds,
|
||||||
|
regionIds,
|
||||||
|
dbInstanceMap,
|
||||||
|
dbGpuMap,
|
||||||
|
dbG8Map,
|
||||||
|
dbVpuMap
|
||||||
|
),
|
||||||
|
180000,
|
||||||
|
`${provider} fetch pricing`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle both return types
|
||||||
|
let pricingCount = 0;
|
||||||
|
if (typeof pricingResult === 'number') {
|
||||||
|
pricingCount = pricingResult;
|
||||||
|
} else if (pricingResult.length > 0) {
|
||||||
|
pricingCount = await this.repos.pricing.upsertMany(pricingResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`${provider} → pricing updated`, { pricing: pricingCount });
|
||||||
|
|
||||||
|
// Stage: Sync Anvil Pricing (if applicable)
|
||||||
|
stage = SyncStage.SYNC_ANVIL_PRICING;
|
||||||
|
let anvilPricingCount = 0;
|
||||||
|
try {
|
||||||
|
anvilPricingCount = await this.syncAnvilPricing(provider);
|
||||||
|
if (anvilPricingCount > 0) {
|
||||||
|
this.logger.info(`${provider} → ${stage}`, { anvil_pricing: anvilPricingCount });
|
||||||
|
}
|
||||||
|
} catch (anvilError) {
|
||||||
|
this.logger.error('Anvil pricing sync failed', {
|
||||||
|
provider,
|
||||||
|
error: anvilError instanceof Error ? anvilError.message : String(anvilError)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync Anvil Transfer Pricing
|
||||||
|
let anvilTransferPricingCount = 0;
|
||||||
|
try {
|
||||||
|
anvilTransferPricingCount = await this.syncAnvilTransferPricing(provider);
|
||||||
|
if (anvilTransferPricingCount > 0) {
|
||||||
|
this.logger.info(`${provider} → SYNC_ANVIL_TRANSFER_PRICING`, { anvil_transfer_pricing: anvilTransferPricingCount });
|
||||||
|
}
|
||||||
|
} catch (transferError) {
|
||||||
|
this.logger.error('Anvil transfer pricing sync failed', {
|
||||||
|
provider,
|
||||||
|
error: transferError instanceof Error ? transferError.message : String(transferError)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete - Update provider status
|
||||||
|
stage = SyncStage.COMPLETE;
|
||||||
|
await this.repos.providers.updateSyncStatus(provider, 'success');
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.info(`${provider} → ${stage} (pricing only)`, { duration_ms: duration });
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
success: true,
|
||||||
|
regions_synced: 0,
|
||||||
|
instances_synced: 0,
|
||||||
|
pricing_synced: pricingCount,
|
||||||
|
duration_ms: duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
this.logger.error(`${provider} pricing sync failed at ${stage}`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stage
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update provider status to error
|
||||||
|
try {
|
||||||
|
await this.repos.providers.updateSyncStatus(provider, 'error', errorMessage);
|
||||||
|
} catch (statusError) {
|
||||||
|
this.logger.error('Failed to update provider status', {
|
||||||
|
error: statusError instanceof Error ? statusError.message : String(statusError)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
success: false,
|
||||||
|
regions_synced: 0,
|
||||||
|
instances_synced: 0,
|
||||||
|
pricing_synced: 0,
|
||||||
|
duration_ms: duration,
|
||||||
|
error: errorMessage,
|
||||||
|
error_details: {
|
||||||
|
stage,
|
||||||
|
message: errorMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronize pricing data only for all providers
|
||||||
|
*
|
||||||
|
* Lightweight sync operation that only updates pricing data.
|
||||||
|
* Skips region and instance type synchronization.
|
||||||
|
*
|
||||||
|
* @param providers - Array of provider names to sync (defaults to all supported providers)
|
||||||
|
* @returns Complete sync report with pricing statistics
|
||||||
|
*/
|
||||||
|
async syncAllPricingOnly(providers: string[] = [...SUPPORTED_PROVIDERS]): Promise<SyncReport> {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
this.logger.info('Starting pricing-only sync for providers', { providers: providers.join(', ') });
|
||||||
|
|
||||||
|
const providerResults: ProviderSyncResult[] = [];
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
try {
|
||||||
|
const result = await this.syncPricingOnly(provider);
|
||||||
|
providerResults.push(result);
|
||||||
|
|
||||||
|
this.logger.info('Provider pricing sync completed', {
|
||||||
|
provider,
|
||||||
|
success: result.success,
|
||||||
|
elapsed_ms: Date.now() - startTime
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
providerResults.push({
|
||||||
|
provider,
|
||||||
|
success: false,
|
||||||
|
regions_synced: 0,
|
||||||
|
instances_synced: 0,
|
||||||
|
pricing_synced: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedAt = new Date().toISOString();
|
||||||
|
const totalDuration = Date.now() - startTime;
|
||||||
|
|
||||||
|
const successful = providerResults.filter(r => r.success);
|
||||||
|
const failed = providerResults.filter(r => !r.success);
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total_providers: providers.length,
|
||||||
|
successful_providers: successful.length,
|
||||||
|
failed_providers: failed.length,
|
||||||
|
total_regions: 0,
|
||||||
|
total_instances: 0,
|
||||||
|
total_pricing: providerResults.reduce((sum, r) => sum + r.pricing_synced, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
const report: SyncReport = {
|
||||||
|
success: failed.length === 0,
|
||||||
|
started_at: startedAt,
|
||||||
|
completed_at: completedAt,
|
||||||
|
total_duration_ms: totalDuration,
|
||||||
|
providers: providerResults,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.info('Pricing sync complete', {
|
||||||
|
total: summary.total_providers,
|
||||||
|
success: summary.successful_providers,
|
||||||
|
failed: summary.failed_providers,
|
||||||
|
duration_ms: totalDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronize all providers
|
* Synchronize all providers
|
||||||
*
|
*
|
||||||
|
|||||||
11
src/types.ts
11
src/types.ts
@@ -416,6 +416,17 @@ export interface Env {
|
|||||||
ENVIRONMENT?: string;
|
ENVIRONMENT?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hono context variables
|
||||||
|
* Shared across all request contexts
|
||||||
|
*/
|
||||||
|
export interface HonoVariables {
|
||||||
|
/** Unique request ID for tracing */
|
||||||
|
requestId: string;
|
||||||
|
/** Authentication status (set by auth middleware) */
|
||||||
|
authenticated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Synchronization Types
|
// Synchronization Types
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user