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 type { Context } from 'hono';
|
||||
import type { Env } from './types';
|
||||
import type { Env, HonoVariables } from './types';
|
||||
import { CORS, HTTP_STATUS } from './constants';
|
||||
import { createLogger } from './utils/logger';
|
||||
import {
|
||||
@@ -21,20 +21,14 @@ import { handleSync } from './routes/sync';
|
||||
|
||||
const logger = createLogger('[App]');
|
||||
|
||||
// Context variables type
|
||||
type Variables = {
|
||||
requestId: string;
|
||||
authenticated?: boolean;
|
||||
};
|
||||
|
||||
// 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
|
||||
* 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 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 = [
|
||||
'price',
|
||||
'hourly_price',
|
||||
'monthly_price',
|
||||
'vcpu',
|
||||
'memory_mb',
|
||||
'memory_gb',
|
||||
'storage_gb',
|
||||
'instance_name',
|
||||
'provider',
|
||||
'region',
|
||||
] as const;
|
||||
export const SORT_FIELD_MAP: Record<string, string> = {
|
||||
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',
|
||||
memory_gb: 'it.memory_mb', // Note: memory_gb is converted to memory_mb at query level
|
||||
storage_gb: 'it.storage_gb',
|
||||
name: 'it.instance_name',
|
||||
instance_name: 'it.instance_name',
|
||||
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
|
||||
|
||||
79
src/index.ts
79
src/index.ts
@@ -20,7 +20,7 @@ export default {
|
||||
*
|
||||
* Cron Schedules:
|
||||
* - 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> {
|
||||
const logger = createLogger('[Cron]', env);
|
||||
@@ -47,17 +47,18 @@ export default {
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
logger.info('Starting sync attempt', {
|
||||
logger.info('Starting full sync attempt', {
|
||||
attempt_number: attempt,
|
||||
max_retries: MAX_RETRIES
|
||||
});
|
||||
|
||||
const report = await orchestrator.syncAll(['linode', 'vultr', 'aws']);
|
||||
|
||||
logger.info('Daily sync complete', {
|
||||
logger.info('Daily full sync complete', {
|
||||
attempt_number: attempt,
|
||||
total_regions: report.summary.total_regions,
|
||||
total_instances: report.summary.total_instances,
|
||||
total_pricing: report.summary.total_pricing,
|
||||
successful_providers: report.summary.successful_providers,
|
||||
failed_providers: report.summary.failed_providers,
|
||||
duration_ms: report.total_duration_ms
|
||||
@@ -111,5 +112,77 @@ export default {
|
||||
|
||||
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 { Env } from '../types';
|
||||
import type { Env, HonoVariables } from '../types';
|
||||
import {
|
||||
authenticateRequest,
|
||||
verifyApiKey,
|
||||
@@ -16,18 +16,12 @@ import { createLogger } from '../utils/logger';
|
||||
|
||||
const logger = createLogger('[Middleware]');
|
||||
|
||||
// Context variables type
|
||||
type Variables = {
|
||||
requestId: string;
|
||||
authenticated?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request ID middleware
|
||||
* Adds unique request ID to context for tracing
|
||||
*/
|
||||
export async function requestIdMiddleware(
|
||||
c: Context<{ Bindings: Env; Variables: Variables }>,
|
||||
c: Context<{ Bindings: Env; Variables: HonoVariables }>,
|
||||
next: Next
|
||||
): Promise<void> {
|
||||
// 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
|
||||
*/
|
||||
export async function authMiddleware(
|
||||
c: Context<{ Bindings: Env; Variables: Variables }>,
|
||||
c: Context<{ Bindings: Env; Variables: HonoVariables }>,
|
||||
next: Next
|
||||
): Promise<Response | void> {
|
||||
const request = c.req.raw;
|
||||
@@ -71,7 +65,7 @@ export async function authMiddleware(
|
||||
* Applies rate limits based on endpoint using existing rate limit logic
|
||||
*/
|
||||
export async function rateLimitMiddleware(
|
||||
c: Context<{ Bindings: Env; Variables: Variables }>,
|
||||
c: Context<{ Bindings: Env; Variables: HonoVariables }>,
|
||||
next: Next
|
||||
): Promise<Response | void> {
|
||||
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
|
||||
*/
|
||||
export async function optionalAuthMiddleware(
|
||||
c: Context<{ Bindings: Env; Variables: Variables }>,
|
||||
c: Context<{ Bindings: Env; Variables: HonoVariables }>,
|
||||
next: Next
|
||||
): Promise<void> {
|
||||
const apiKey = c.req.header('X-API-Key');
|
||||
|
||||
@@ -4,18 +4,12 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import { Env } from '../types';
|
||||
import { Env, HonoVariables } from '../types';
|
||||
import { HTTP_STATUS } from '../constants';
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
const logger = createLogger('[Health]');
|
||||
|
||||
// Context variables type
|
||||
type Variables = {
|
||||
requestId: string;
|
||||
authenticated?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component health status
|
||||
*/
|
||||
@@ -169,7 +163,7 @@ function sanitizeError(error: string): string {
|
||||
* @param c - Hono context
|
||||
*/
|
||||
export async function handleHealth(
|
||||
c: Context<{ Bindings: Env; Variables: Variables }>
|
||||
c: Context<{ Bindings: Env; Variables: HonoVariables }>
|
||||
): Promise<Response> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const authenticated = c.get('authenticated') ?? false;
|
||||
@@ -186,7 +180,7 @@ export async function handleHealth(
|
||||
status: 'unhealthy',
|
||||
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
|
||||
@@ -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
|
||||
@@ -296,7 +290,7 @@ export async function handleHealth(
|
||||
status: overallStatus,
|
||||
timestamp,
|
||||
};
|
||||
return Response.json(publicResponse, { status: statusCode });
|
||||
return c.json(publicResponse, statusCode);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
logger.error('Health check failed', { error });
|
||||
|
||||
@@ -329,7 +323,7 @@ export async function handleHealth(
|
||||
status: 'unhealthy',
|
||||
timestamp,
|
||||
};
|
||||
return Response.json(publicResponse, { status: HTTP_STATUS.SERVICE_UNAVAILABLE });
|
||||
return c.json(publicResponse, HTTP_STATUS.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// 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 { Env, InstanceQueryParams } from '../types';
|
||||
import type { Env, HonoVariables, InstanceQueryParams } from '../types';
|
||||
import { QueryService } from '../services/query';
|
||||
import { getGlobalCacheService } from '../services/cache';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Context variables type
|
||||
type Variables = {
|
||||
requestId: string;
|
||||
authenticated?: boolean;
|
||||
};
|
||||
import {
|
||||
SUPPORTED_PROVIDERS,
|
||||
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
|
||||
*/
|
||||
export async function handleInstances(
|
||||
c: Context<{ Bindings: Env; Variables: Variables }>
|
||||
c: Context<{ Bindings: Env; Variables: HonoVariables }>
|
||||
): Promise<Response> {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -339,12 +333,12 @@ export async function handleInstances(
|
||||
// Handle validation errors
|
||||
if (parseResult.error) {
|
||||
logger.error('[Instances] Validation error', parseResult.error);
|
||||
return Response.json(
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
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,
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
@@ -396,11 +390,9 @@ export async function handleInstances(
|
||||
},
|
||||
},
|
||||
},
|
||||
HTTP_STATUS.OK,
|
||||
{
|
||||
status: HTTP_STATUS.OK,
|
||||
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) });
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: responseData,
|
||||
},
|
||||
HTTP_STATUS.OK,
|
||||
{
|
||||
status: HTTP_STATUS.OK,
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${CACHE_TTL.INSTANCES}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[Instances] Unexpected error', { error });
|
||||
|
||||
return Response.json(
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
@@ -488,7 +478,7 @@ export async function handleInstances(
|
||||
request_id: crypto.randomUUID(),
|
||||
},
|
||||
},
|
||||
{ status: HTTP_STATUS.INTERNAL_ERROR }
|
||||
HTTP_STATUS.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,12 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import type { Env } from '../types';
|
||||
import type { Env, HonoVariables } from '../types';
|
||||
import { SyncOrchestrator } from '../services/sync';
|
||||
import { logger } from '../utils/logger';
|
||||
import { SUPPORTED_PROVIDERS, HTTP_STATUS } from '../constants';
|
||||
import { parseJsonBody, validateProviders, createErrorResponse } from '../utils/validation';
|
||||
|
||||
// Context variables type
|
||||
type Variables = {
|
||||
requestId: string;
|
||||
authenticated?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request body interface for sync endpoint
|
||||
*/
|
||||
@@ -41,7 +35,7 @@ interface SyncRequestBody {
|
||||
* }
|
||||
*/
|
||||
export async function handleSync(
|
||||
c: Context<{ Bindings: Env; Variables: Variables }>
|
||||
c: Context<{ Bindings: Env; Variables: HonoVariables }>
|
||||
): Promise<Response> {
|
||||
const startTime = Date.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
@@ -54,9 +48,9 @@ export async function handleSync(
|
||||
if (contentLength) {
|
||||
const bodySize = parseInt(contentLength, 10);
|
||||
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' } },
|
||||
{ status: 413 }
|
||||
413
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,7 +105,7 @@ export async function handleSync(
|
||||
summary: syncReport.summary
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
return c.json(
|
||||
{
|
||||
success: syncReport.success,
|
||||
data: {
|
||||
@@ -119,7 +113,7 @@ export async function handleSync(
|
||||
...syncReport
|
||||
}
|
||||
},
|
||||
{ status: HTTP_STATUS.OK }
|
||||
HTTP_STATUS.OK
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
@@ -128,7 +122,7 @@ export async function handleSync(
|
||||
const completedAt = new Date().toISOString();
|
||||
const totalDuration = Date.now() - startTime;
|
||||
|
||||
return Response.json(
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
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 { SORT_FIELD_MAP } from '../constants';
|
||||
import type {
|
||||
Env,
|
||||
InstanceQueryParams,
|
||||
@@ -299,19 +300,8 @@ export class QueryService {
|
||||
// Validate sort order at service level (defense in depth)
|
||||
const validatedSortOrder = sortOrder?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
|
||||
// Map sort fields to actual column names
|
||||
const sortFieldMap: Record<string, string> = {
|
||||
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';
|
||||
// Map sort fields to actual column names (imported from constants.ts)
|
||||
const sortColumn = SORT_FIELD_MAP[sortBy] ?? 'pr.hourly_price';
|
||||
|
||||
// Handle NULL values in pricing columns (NULL values go last)
|
||||
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
|
||||
*
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -416,6 +416,17 @@ export interface Env {
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user