feat: KRW 가격 지원 및 GPU/G8/VPU 인스턴스 추가

## KRW 가격 기능
- pricing 테이블에 hourly_price_krw, monthly_price_krw 컬럼 추가
- 부가세 10% + 영업이익 10% + 환율 적용 (기본 1450원)
- 시간당: 1원 단위 반올림 (최소 1원)
- 월간: 100원 단위 반올림 (최소 100원)
- 환율/부가세/영업이익률 환경변수로 분리 (배포 없이 변경 가능)

## GPU/G8/VPU 인스턴스 지원
- gpu_instances, gpu_pricing 테이블 추가
- g8_instances, g8_pricing 테이블 추가
- vpu_instances, vpu_pricing 테이블 추가
- Linode/Vultr 커넥터에 GPU 동기화 로직 추가

## 환경변수 추가
- KRW_EXCHANGE_RATE: 환율 (기본 1450)
- KRW_VAT_RATE: 부가세율 (기본 1.1)
- KRW_MARKUP_RATE: 영업이익률 (기본 1.1)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-22 18:57:51 +09:00
parent b1cb844c05
commit a2133ae5c9
20 changed files with 3517 additions and 690 deletions

View File

@@ -1,4 +1,4 @@
import type { Env, RegionInput, InstanceTypeInput, InstanceFamily } from '../types';
import type { Env, RegionInput, InstanceTypeInput, InstanceFamily, GpuInstanceInput, G8InstanceInput, VpuInstanceInput } from '../types';
import { VaultClient, VaultError } from './vault';
import { RateLimiter } from './base';
import { createLogger } from '../utils/logger';
@@ -186,6 +186,133 @@ export class LinodeConnector {
};
}
/**
* Normalize Linode GPU instance type data for database storage
*
* @param raw - Raw Linode instance type data with GPU
* @param providerId - Database provider ID
* @returns Normalized GPU instance data ready for insertion
*/
normalizeGpuInstance(raw: LinodeInstanceType, providerId: number): GpuInstanceInput {
// Extract GPU memory from metadata if available
// Linode doesn't provide GPU memory in API, set to null
const gpuMemoryGb = null;
return {
provider_id: providerId,
instance_id: raw.id,
instance_name: raw.label,
vcpu: raw.vcpus,
memory_mb: raw.memory,
storage_gb: Math.round(raw.disk / 1024),
transfer_tb: raw.transfer / 1000,
network_speed_gbps: raw.network_out / 1000,
gpu_count: raw.gpus,
gpu_type: this.extractGpuType(raw),
gpu_memory_gb: gpuMemoryGb,
metadata: JSON.stringify({
class: raw.class,
hourly_price: raw.price.hourly,
monthly_price: raw.price.monthly,
}),
};
}
/**
* Normalize Linode G8 generation instance data for database storage
*
* @param raw - Raw Linode instance type data
* @param providerId - Database provider ID
* @returns Normalized G8 instance data ready for insertion
*/
normalizeG8Instance(raw: LinodeInstanceType, providerId: number): G8InstanceInput {
return {
provider_id: providerId,
instance_id: raw.id,
instance_name: raw.label,
vcpu: raw.vcpus,
memory_mb: raw.memory,
storage_gb: Math.round(raw.disk / 1024),
transfer_tb: raw.transfer / 1000,
network_speed_gbps: raw.network_out / 1000,
metadata: JSON.stringify({
class: raw.class,
hourly_price: raw.price.hourly,
monthly_price: raw.price.monthly,
}),
};
}
/**
* Normalize Linode VPU instance type data for database storage
*
* @param raw - Raw Linode instance type data with VPU
* @param providerId - Database provider ID
* @returns Normalized VPU instance data ready for insertion
*/
normalizeVpuInstance(raw: LinodeInstanceType, providerId: number): VpuInstanceInput {
return {
provider_id: providerId,
instance_id: raw.id,
instance_name: raw.label,
vcpu: raw.vcpus,
memory_mb: raw.memory,
storage_gb: Math.round(raw.disk / 1024),
transfer_tb: raw.transfer / 1000,
network_speed_gbps: raw.network_out / 1000,
vpu_type: this.extractVpuType(raw),
metadata: JSON.stringify({
class: raw.class,
hourly_price: raw.price.hourly,
monthly_price: raw.price.monthly,
}),
};
}
/**
* Extract GPU type from Linode instance data
* Linode uses NVIDIA GPUs, but specific model info may vary
*
* @param raw - Raw Linode instance type data
* @returns GPU type string
*/
private extractGpuType(raw: LinodeInstanceType): string {
// Check label for specific GPU model information
const labelLower = raw.label.toLowerCase();
if (labelLower.includes('rtx6000')) {
return 'NVIDIA RTX6000';
}
if (labelLower.includes('a100')) {
return 'NVIDIA A100';
}
if (labelLower.includes('v100')) {
return 'NVIDIA V100';
}
if (labelLower.includes('rtx')) {
return 'NVIDIA RTX';
}
// Default to generic NVIDIA for GPU instances
return 'NVIDIA GPU';
}
/**
* Extract VPU type from Linode instance data
*
* @param raw - Raw Linode instance type data
* @returns VPU type string
*/
private extractVpuType(raw: LinodeInstanceType): string {
const labelLower = raw.label.toLowerCase();
if (labelLower.includes('netint')) {
return 'NETINT Quadra';
}
return 'VPU Accelerator';
}
/**
* Map Linode instance class to standard instance family
*

View File

@@ -1,4 +1,4 @@
import type { Env, RegionInput, InstanceTypeInput, InstanceFamily } from '../types';
import type { Env, RegionInput, InstanceTypeInput, InstanceFamily, GpuInstanceInput } from '../types';
import { VaultClient, VaultError } from './vault';
import { RateLimiter } from './base';
import { createLogger } from '../utils/logger';
@@ -210,6 +210,42 @@ export class VultrConnector {
};
}
/**
* Normalize Vultr GPU plan data for database storage
*
* @param raw - Raw Vultr plan data for GPU instance (vcg type)
* @param providerId - Database provider ID
* @returns Normalized GPU instance data ready for insertion
*/
normalizeGpuInstance(raw: VultrPlan, providerId: number): GpuInstanceInput {
const hourlyPrice = raw.monthly_cost / 730;
// Extract GPU type from vcg prefix
// vcg-* instances are NVIDIA-based GPU instances
const gpuType = 'NVIDIA';
return {
provider_id: providerId,
instance_id: raw.id,
instance_name: raw.id,
vcpu: raw.vcpu_count,
memory_mb: raw.ram,
storage_gb: raw.disk,
transfer_tb: raw.bandwidth / 1000,
network_speed_gbps: null,
gpu_count: 1, // Vultr vcg instances have 1 GPU
gpu_type: gpuType,
gpu_memory_gb: null, // Vultr doesn't expose GPU memory in plans API
metadata: JSON.stringify({
type: raw.type,
disk_count: raw.disk_count,
locations: raw.locations,
hourly_price: hourlyPrice,
monthly_price: raw.monthly_cost,
}),
};
}
/**
* Map Vultr instance type to standard instance family
*

View File

@@ -176,6 +176,7 @@ export const CORS = {
ALLOWED_ORIGINS: [
'https://anvil.it.com',
'https://cloud.anvil.it.com',
'https://hosting.anvil.it.com',
'http://localhost:3000', // DEVELOPMENT ONLY - exclude in production
] as string[],
/** Max age for CORS preflight cache (24 hours) */
@@ -223,3 +224,90 @@ export const REQUEST_LIMITS = {
/** Maximum request body size in bytes (10KB) */
MAX_BODY_SIZE: 10 * 1024,
} as const;
// ============================================================
// KRW Pricing Configuration
// ============================================================
/**
* Default KRW (Korean Won) pricing configuration
*
* These defaults are used when environment variables are not set.
* Calculation formula:
* KRW = USD × VAT (1.1) × Markup (1.1) × Exchange Rate (1450)
* KRW = USD × 1754.5
*/
export const KRW_PRICING_DEFAULTS = {
/** VAT multiplier (10% VAT) */
VAT_MULTIPLIER: 1.1,
/** Markup multiplier (10% markup) */
MARKUP_MULTIPLIER: 1.1,
/** USD to KRW exchange rate */
EXCHANGE_RATE: 1450,
} as const;
/**
* KRW pricing configuration interface
*/
export interface KRWConfig {
exchangeRate: number;
vatRate: number;
markupRate: number;
totalMultiplier: number;
}
/**
* Get KRW pricing configuration from environment variables
* Falls back to default values if env vars are not set
*
* @param env - Cloudflare Worker environment
* @returns KRW pricing configuration
*/
export function getKRWConfig(env?: { KRW_EXCHANGE_RATE?: string; KRW_VAT_RATE?: string; KRW_MARKUP_RATE?: string }): KRWConfig {
const exchangeRate = env?.KRW_EXCHANGE_RATE ? parseFloat(env.KRW_EXCHANGE_RATE) : KRW_PRICING_DEFAULTS.EXCHANGE_RATE;
const vatRate = env?.KRW_VAT_RATE ? parseFloat(env.KRW_VAT_RATE) : KRW_PRICING_DEFAULTS.VAT_MULTIPLIER;
const markupRate = env?.KRW_MARKUP_RATE ? parseFloat(env.KRW_MARKUP_RATE) : KRW_PRICING_DEFAULTS.MARKUP_MULTIPLIER;
return {
exchangeRate,
vatRate,
markupRate,
totalMultiplier: vatRate * markupRate * exchangeRate,
};
}
/**
* Calculate KRW hourly price from USD price
* Applies VAT, markup, and exchange rate conversion
*
* @param usd - Hourly price in USD
* @param env - Optional environment for custom rates (uses defaults if not provided)
* @returns Price in KRW, rounded to nearest 1 KRW (minimum 1 KRW)
*
* @example
* calculateKRWHourly(0.0075) // Returns 13 (with defaults)
* calculateKRWHourly(0.144) // Returns 253 (with defaults)
*/
export function calculateKRWHourly(usd: number, env?: { KRW_EXCHANGE_RATE?: string; KRW_VAT_RATE?: string; KRW_MARKUP_RATE?: string }): number {
const config = getKRWConfig(env);
const krw = Math.round(usd * config.totalMultiplier);
return Math.max(krw, 1);
}
/**
* Calculate KRW monthly price from USD price
* Applies VAT, markup, and exchange rate conversion
*
* @param usd - Monthly price in USD
* @param env - Optional environment for custom rates (uses defaults if not provided)
* @returns Price in KRW, rounded to nearest 100 KRW (minimum 100 KRW)
*
* @example
* calculateKRWMonthly(5) // Returns 8800 (with defaults)
* calculateKRWMonthly(96) // Returns 168400 (with defaults)
*/
export function calculateKRWMonthly(usd: number, env?: { KRW_EXCHANGE_RATE?: string; KRW_VAT_RATE?: string; KRW_MARKUP_RATE?: string }): number {
const config = getKRWConfig(env);
const krw = Math.round(usd * config.totalMultiplier / 100) * 100;
return Math.max(krw, 100);
}

View File

@@ -0,0 +1,135 @@
/**
* G8 Instances Repository
* Handles CRUD operations for G8 generation Dedicated instance types
*/
import { BaseRepository } from './base';
import { G8Instance, G8InstanceInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
export class G8InstancesRepository extends BaseRepository<G8Instance> {
protected tableName = 'g8_instances';
protected logger = createLogger('[G8InstancesRepository]');
protected allowedColumns = [
'provider_id',
'instance_id',
'instance_name',
'vcpu',
'memory_mb',
'storage_gb',
'transfer_tb',
'network_speed_gbps',
'metadata',
];
/**
* Find all G8 instances for a specific provider
*/
async findByProvider(providerId: number): Promise<G8Instance[]> {
try {
const result = await this.db
.prepare('SELECT * FROM g8_instances WHERE provider_id = ?')
.bind(providerId)
.all<G8Instance>();
return result.results;
} catch (error) {
this.logger.error('findByProvider failed', {
providerId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find G8 instances for provider: ${providerId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find G8 instance by provider and instance ID
*/
async findByInstanceId(providerId: number, instanceId: string): Promise<G8Instance | null> {
try {
const result = await this.db
.prepare('SELECT * FROM g8_instances WHERE provider_id = ? AND instance_id = ?')
.bind(providerId, instanceId)
.first<G8Instance>();
return result || null;
} catch (error) {
this.logger.error('findByInstanceId failed', {
providerId,
instanceId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find G8 instance: ${instanceId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Upsert multiple G8 instances (batch operation)
*/
async upsertMany(providerId: number, instances: G8InstanceInput[]): Promise<number> {
if (instances.length === 0) {
return 0;
}
try {
const statements = instances.map((instance) => {
return this.db.prepare(
`INSERT INTO g8_instances (
provider_id, instance_id, instance_name, vcpu, memory_mb,
storage_gb, transfer_tb, network_speed_gbps, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider_id, instance_id)
DO UPDATE SET
instance_name = excluded.instance_name,
vcpu = excluded.vcpu,
memory_mb = excluded.memory_mb,
storage_gb = excluded.storage_gb,
transfer_tb = excluded.transfer_tb,
network_speed_gbps = excluded.network_speed_gbps,
metadata = excluded.metadata,
updated_at = datetime('now')`
).bind(
providerId,
instance.instance_id,
instance.instance_name,
instance.vcpu,
instance.memory_mb,
instance.storage_gb,
instance.transfer_tb,
instance.network_speed_gbps,
instance.metadata
);
});
const results = await this.db.batch(statements);
const successCount = results.filter((r) => r.success).length;
this.logger.info('upsertMany completed', {
providerId,
total: instances.length,
success: successCount,
});
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
providerId,
count: instances.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to upsert G8 instances for provider: ${providerId}`,
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
}

View File

@@ -0,0 +1,138 @@
/**
* G8 Pricing Repository
* Handles CRUD operations for G8 instance pricing data
*/
import { BaseRepository } from './base';
import { G8Pricing, G8PricingInput, RepositoryError, ErrorCodes, Env } from '../types';
import { createLogger } from '../utils/logger';
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
export class G8PricingRepository extends BaseRepository<G8Pricing> {
protected tableName = 'g8_pricing';
protected logger = createLogger('[G8PricingRepository]');
protected allowedColumns = [
'g8_instance_id',
'region_id',
'hourly_price',
'monthly_price',
'hourly_price_krw',
'monthly_price_krw',
'currency',
'available',
];
constructor(db: D1Database, private env?: Env) {
super(db);
}
/**
* Find all pricing records for a specific G8 instance
*/
async findByG8Instance(g8InstanceId: number): Promise<G8Pricing[]> {
try {
const result = await this.db
.prepare('SELECT * FROM g8_pricing WHERE g8_instance_id = ?')
.bind(g8InstanceId)
.all<G8Pricing>();
return result.results;
} catch (error) {
this.logger.error('findByG8Instance failed', {
g8InstanceId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find pricing for G8 instance: ${g8InstanceId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find pricing for a specific G8 instance in a specific region
*/
async findByInstanceAndRegion(g8InstanceId: number, regionId: number): Promise<G8Pricing | null> {
try {
const result = await this.db
.prepare('SELECT * FROM g8_pricing WHERE g8_instance_id = ? AND region_id = ?')
.bind(g8InstanceId, regionId)
.first<G8Pricing>();
return result || null;
} catch (error) {
this.logger.error('findByInstanceAndRegion failed', {
g8InstanceId,
regionId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find pricing for G8 instance ${g8InstanceId} in region ${regionId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Upsert multiple G8 pricing records (batch operation)
*/
async upsertMany(pricingData: G8PricingInput[]): Promise<number> {
if (pricingData.length === 0) {
return 0;
}
try {
const statements = pricingData.map((pricing) => {
const hourlyKrw = calculateKRWHourly(pricing.hourly_price, this.env);
const monthlyKrw = calculateKRWMonthly(pricing.monthly_price, this.env);
return this.db.prepare(
`INSERT INTO g8_pricing (
g8_instance_id, region_id, hourly_price, monthly_price,
hourly_price_krw, monthly_price_krw, currency, available
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(g8_instance_id, region_id)
DO UPDATE SET
hourly_price = excluded.hourly_price,
monthly_price = excluded.monthly_price,
hourly_price_krw = excluded.hourly_price_krw,
monthly_price_krw = excluded.monthly_price_krw,
currency = excluded.currency,
available = excluded.available,
updated_at = datetime('now')`
).bind(
pricing.g8_instance_id,
pricing.region_id,
pricing.hourly_price,
pricing.monthly_price,
hourlyKrw,
monthlyKrw,
pricing.currency,
pricing.available
);
});
const results = await this.db.batch(statements);
const successCount = results.filter((r) => r.success).length;
this.logger.info('upsertMany completed', {
total: pricingData.length,
success: successCount,
});
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
count: pricingData.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to upsert G8 pricing records',
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
}

View File

@@ -0,0 +1,274 @@
/**
* GPU Instances Repository
* Handles CRUD operations for GPU-specific instance types
*/
import { BaseRepository } from './base';
import { GpuInstance, GpuInstanceInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
export class GpuInstancesRepository extends BaseRepository<GpuInstance> {
protected tableName = 'gpu_instances';
protected logger = createLogger('[GpuInstancesRepository]');
protected allowedColumns = [
'provider_id',
'instance_id',
'instance_name',
'vcpu',
'memory_mb',
'storage_gb',
'transfer_tb',
'network_speed_gbps',
'gpu_count',
'gpu_type',
'gpu_memory_gb',
'metadata',
];
/**
* Find all GPU instances for a specific provider
*/
async findByProvider(providerId: number): Promise<GpuInstance[]> {
try {
const result = await this.db
.prepare('SELECT * FROM gpu_instances WHERE provider_id = ?')
.bind(providerId)
.all<GpuInstance>();
return result.results;
} catch (error) {
this.logger.error('findByProvider failed', {
providerId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find GPU instances for provider: ${providerId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find GPU instances by GPU type
*/
async findByGpuType(gpuType: string): Promise<GpuInstance[]> {
try {
const result = await this.db
.prepare('SELECT * FROM gpu_instances WHERE gpu_type = ?')
.bind(gpuType)
.all<GpuInstance>();
return result.results;
} catch (error) {
this.logger.error('findByGpuType failed', {
gpuType,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find GPU instances by type: ${gpuType}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find a GPU instance by provider ID and instance ID
*/
async findByInstanceId(providerId: number, instanceId: string): Promise<GpuInstance | null> {
try {
const result = await this.db
.prepare('SELECT * FROM gpu_instances WHERE provider_id = ? AND instance_id = ?')
.bind(providerId, instanceId)
.first<GpuInstance>();
return result || null;
} catch (error) {
this.logger.error('findByInstanceId failed', {
providerId,
instanceId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find GPU instance: ${instanceId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Bulk upsert GPU instances for a provider
* Uses batch operations for efficiency
*/
async upsertMany(providerId: number, instances: GpuInstanceInput[]): Promise<number> {
if (instances.length === 0) {
return 0;
}
try {
// Build upsert statements for each GPU instance
const statements = instances.map((instance) => {
return this.db.prepare(
`INSERT INTO gpu_instances (
provider_id, instance_id, instance_name, vcpu, memory_mb,
storage_gb, transfer_tb, network_speed_gbps, gpu_count,
gpu_type, gpu_memory_gb, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider_id, instance_id)
DO UPDATE SET
instance_name = excluded.instance_name,
vcpu = excluded.vcpu,
memory_mb = excluded.memory_mb,
storage_gb = excluded.storage_gb,
transfer_tb = excluded.transfer_tb,
network_speed_gbps = excluded.network_speed_gbps,
gpu_count = excluded.gpu_count,
gpu_type = excluded.gpu_type,
gpu_memory_gb = excluded.gpu_memory_gb,
metadata = excluded.metadata`
).bind(
providerId,
instance.instance_id,
instance.instance_name,
instance.vcpu,
instance.memory_mb,
instance.storage_gb,
instance.transfer_tb || null,
instance.network_speed_gbps || null,
instance.gpu_count,
instance.gpu_type,
instance.gpu_memory_gb || null,
instance.metadata || null
);
});
const results = await this.executeBatch(statements);
// Count successful operations
const successCount = results.reduce(
(sum, result) => sum + (result.meta.changes ?? 0),
0
);
this.logger.info('Upserted GPU instances', {
providerId,
count: successCount
});
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
providerId,
count: instances.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to upsert GPU instances for provider: ${providerId}`,
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
/**
* Search GPU instances by specifications
*/
async search(criteria: {
providerId?: number;
minVcpu?: number;
maxVcpu?: number;
minMemoryMb?: number;
maxMemoryMb?: number;
minGpuCount?: number;
gpuType?: string;
minGpuMemoryGb?: number;
}): Promise<GpuInstance[]> {
try {
const conditions: string[] = [];
const params: (string | number | boolean | null)[] = [];
if (criteria.providerId !== undefined) {
conditions.push('provider_id = ?');
params.push(criteria.providerId);
}
if (criteria.minVcpu !== undefined) {
conditions.push('vcpu >= ?');
params.push(criteria.minVcpu);
}
if (criteria.maxVcpu !== undefined) {
conditions.push('vcpu <= ?');
params.push(criteria.maxVcpu);
}
if (criteria.minMemoryMb !== undefined) {
conditions.push('memory_mb >= ?');
params.push(criteria.minMemoryMb);
}
if (criteria.maxMemoryMb !== undefined) {
conditions.push('memory_mb <= ?');
params.push(criteria.maxMemoryMb);
}
if (criteria.minGpuCount !== undefined) {
conditions.push('gpu_count >= ?');
params.push(criteria.minGpuCount);
}
if (criteria.gpuType !== undefined) {
conditions.push('gpu_type = ?');
params.push(criteria.gpuType);
}
if (criteria.minGpuMemoryGb !== undefined) {
conditions.push('gpu_memory_gb >= ?');
params.push(criteria.minGpuMemoryGb);
}
const whereClause = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
const query = 'SELECT * FROM gpu_instances' + whereClause;
const result = await this.db
.prepare(query)
.bind(...params)
.all<GpuInstance>();
return result.results;
} catch (error) {
this.logger.error('search failed', {
criteria,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to search GPU instances',
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Get distinct GPU types available in the database
*/
async getAvailableGpuTypes(): Promise<string[]> {
try {
const result = await this.db
.prepare('SELECT DISTINCT gpu_type FROM gpu_instances ORDER BY gpu_type')
.all<{ gpu_type: string }>();
return result.results.map(row => row.gpu_type);
} catch (error) {
this.logger.error('getAvailableGpuTypes failed', {
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to get available GPU types',
ErrorCodes.DATABASE_ERROR,
error
);
}
}
}

View File

@@ -0,0 +1,223 @@
/**
* GPU Pricing Repository
* Handles CRUD operations for GPU instance pricing data
*/
import { BaseRepository } from './base';
import { GpuPricing, GpuPricingInput, RepositoryError, ErrorCodes, Env } from '../types';
import { createLogger } from '../utils/logger';
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
export class GpuPricingRepository extends BaseRepository<GpuPricing> {
protected tableName = 'gpu_pricing';
protected logger = createLogger('[GpuPricingRepository]');
protected allowedColumns = [
'gpu_instance_id',
'region_id',
'hourly_price',
'monthly_price',
'hourly_price_krw',
'monthly_price_krw',
'currency',
'available',
];
constructor(db: D1Database, private env?: Env) {
super(db);
}
/**
* Find pricing for a specific GPU instance
*/
async findByGpuInstance(gpuInstanceId: number): Promise<GpuPricing[]> {
try {
const result = await this.db
.prepare('SELECT * FROM gpu_pricing WHERE gpu_instance_id = ?')
.bind(gpuInstanceId)
.all<GpuPricing>();
return result.results;
} catch (error) {
this.logger.error('findByGpuInstance failed', {
gpuInstanceId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find pricing for GPU instance: ${gpuInstanceId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find pricing for a specific region
*/
async findByRegion(regionId: number): Promise<GpuPricing[]> {
try {
const result = await this.db
.prepare('SELECT * FROM gpu_pricing WHERE region_id = ?')
.bind(regionId)
.all<GpuPricing>();
return result.results;
} catch (error) {
this.logger.error('findByRegion failed', {
regionId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find GPU pricing for region: ${regionId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find specific pricing record by GPU instance and region
*/
async findByGpuInstanceAndRegion(
gpuInstanceId: number,
regionId: number
): Promise<GpuPricing | null> {
try {
const result = await this.db
.prepare('SELECT * FROM gpu_pricing WHERE gpu_instance_id = ? AND region_id = ?')
.bind(gpuInstanceId, regionId)
.first<GpuPricing>();
return result || null;
} catch (error) {
this.logger.error('findByGpuInstanceAndRegion failed', {
gpuInstanceId,
regionId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find GPU pricing for instance ${gpuInstanceId} in region ${regionId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Bulk upsert GPU pricing records
* Uses batch operations for efficiency
*/
async upsertMany(pricingData: GpuPricingInput[]): Promise<number> {
if (pricingData.length === 0) {
return 0;
}
try {
const statements = pricingData.map((pricing) => {
const hourlyKrw = calculateKRWHourly(pricing.hourly_price, this.env);
const monthlyKrw = calculateKRWMonthly(pricing.monthly_price, this.env);
return this.db.prepare(
`INSERT INTO gpu_pricing (
gpu_instance_id, region_id, hourly_price, monthly_price,
hourly_price_krw, monthly_price_krw, currency, available
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(gpu_instance_id, region_id)
DO UPDATE SET
hourly_price = excluded.hourly_price,
monthly_price = excluded.monthly_price,
hourly_price_krw = excluded.hourly_price_krw,
monthly_price_krw = excluded.monthly_price_krw,
currency = excluded.currency,
available = excluded.available`
).bind(
pricing.gpu_instance_id,
pricing.region_id,
pricing.hourly_price,
pricing.monthly_price,
hourlyKrw,
monthlyKrw,
pricing.currency,
pricing.available
);
});
const results = await this.executeBatch(statements);
const successCount = results.reduce(
(sum, result) => sum + (result.meta.changes ?? 0),
0
);
this.logger.info('Upserted GPU pricing records', { count: successCount });
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
count: pricingData.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to upsert GPU pricing records',
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
/**
* Search GPU pricing by price range
*/
async searchByPriceRange(
minHourly?: number,
maxHourly?: number,
minMonthly?: number,
maxMonthly?: number
): Promise<GpuPricing[]> {
try {
const conditions: string[] = [];
const params: (string | number | boolean | null)[] = [];
if (minHourly !== undefined) {
conditions.push('hourly_price >= ?');
params.push(minHourly);
}
if (maxHourly !== undefined) {
conditions.push('hourly_price <= ?');
params.push(maxHourly);
}
if (minMonthly !== undefined) {
conditions.push('monthly_price >= ?');
params.push(minMonthly);
}
if (maxMonthly !== undefined) {
conditions.push('monthly_price <= ?');
params.push(maxMonthly);
}
const whereClause = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
const query = 'SELECT * FROM gpu_pricing' + whereClause + ' ORDER BY hourly_price';
const result = await this.db
.prepare(query)
.bind(...params)
.all<GpuPricing>();
return result.results;
} catch (error) {
this.logger.error('searchByPriceRange failed', {
minHourly,
maxHourly,
minMonthly,
maxMonthly,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to search GPU pricing by price range',
ErrorCodes.DATABASE_ERROR,
error
);
}
}
}

View File

@@ -8,11 +8,24 @@ export { ProvidersRepository } from './providers';
export { RegionsRepository } from './regions';
export { InstancesRepository } from './instances';
export { PricingRepository } from './pricing';
export { GpuInstancesRepository } from './gpu-instances';
export { GpuPricingRepository } from './gpu-pricing';
export { G8InstancesRepository } from './g8-instances';
export { G8PricingRepository } from './g8-pricing';
export { VpuInstancesRepository } from './vpu-instances';
export { VpuPricingRepository } from './vpu-pricing';
import { ProvidersRepository } from './providers';
import { RegionsRepository } from './regions';
import { InstancesRepository } from './instances';
import { PricingRepository } from './pricing';
import { GpuInstancesRepository } from './gpu-instances';
import { GpuPricingRepository } from './gpu-pricing';
import { G8InstancesRepository } from './g8-instances';
import { G8PricingRepository } from './g8-pricing';
import { VpuInstancesRepository } from './vpu-instances';
import { VpuPricingRepository } from './vpu-pricing';
import type { Env } from '../types';
/**
* Repository factory for creating repository instances
@@ -23,8 +36,14 @@ export class RepositoryFactory {
private _regions?: RegionsRepository;
private _instances?: InstancesRepository;
private _pricing?: PricingRepository;
private _gpuInstances?: GpuInstancesRepository;
private _gpuPricing?: GpuPricingRepository;
private _g8Instances?: G8InstancesRepository;
private _g8Pricing?: G8PricingRepository;
private _vpuInstances?: VpuInstancesRepository;
private _vpuPricing?: VpuPricingRepository;
constructor(private _db: D1Database) {}
constructor(private _db: D1Database, private _env?: Env) {}
/**
* Access to raw D1 database instance for advanced operations (e.g., batch queries)
@@ -33,6 +52,13 @@ export class RepositoryFactory {
return this._db;
}
/**
* Access to environment variables for KRW pricing configuration
*/
get env(): Env | undefined {
return this._env;
}
get providers(): ProvidersRepository {
return this._providers ??= new ProvidersRepository(this.db);
}
@@ -46,6 +72,30 @@ export class RepositoryFactory {
}
get pricing(): PricingRepository {
return this._pricing ??= new PricingRepository(this.db);
return this._pricing ??= new PricingRepository(this.db, this._env);
}
get gpuInstances(): GpuInstancesRepository {
return this._gpuInstances ??= new GpuInstancesRepository(this.db);
}
get gpuPricing(): GpuPricingRepository {
return this._gpuPricing ??= new GpuPricingRepository(this.db, this._env);
}
get g8Instances(): G8InstancesRepository {
return this._g8Instances ??= new G8InstancesRepository(this.db);
}
get g8Pricing(): G8PricingRepository {
return this._g8Pricing ??= new G8PricingRepository(this.db, this._env);
}
get vpuInstances(): VpuInstancesRepository {
return this._vpuInstances ??= new VpuInstancesRepository(this.db);
}
get vpuPricing(): VpuPricingRepository {
return this._vpuPricing ??= new VpuPricingRepository(this.db, this._env);
}
}

View File

@@ -4,8 +4,9 @@
*/
import { BaseRepository } from './base';
import { Pricing, PricingInput, PriceHistory, RepositoryError, ErrorCodes } from '../types';
import { Pricing, PricingInput, PriceHistory, RepositoryError, ErrorCodes, Env } from '../types';
import { createLogger } from '../utils/logger';
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
export class PricingRepository extends BaseRepository<Pricing> {
protected tableName = 'pricing';
@@ -15,10 +16,16 @@ export class PricingRepository extends BaseRepository<Pricing> {
'region_id',
'hourly_price',
'monthly_price',
'hourly_price_krw',
'monthly_price_krw',
'currency',
'available',
];
constructor(db: D1Database, private env?: Env) {
super(db);
}
/**
* Find pricing records for a specific instance type
*/
@@ -107,15 +114,20 @@ export class PricingRepository extends BaseRepository<Pricing> {
try {
// Build upsert statements for each pricing record
const statements = pricing.map((price) => {
const hourlyKrw = calculateKRWHourly(price.hourly_price, this.env);
const monthlyKrw = calculateKRWMonthly(price.monthly_price, this.env);
return this.db.prepare(
`INSERT INTO pricing (
instance_type_id, region_id, hourly_price, monthly_price,
currency, available
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(instance_type_id, region_id)
instance_type_id, region_id, hourly_price, monthly_price,
hourly_price_krw, monthly_price_krw, currency, available
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(instance_type_id, region_id)
DO UPDATE SET
hourly_price = excluded.hourly_price,
monthly_price = excluded.monthly_price,
hourly_price_krw = excluded.hourly_price_krw,
monthly_price_krw = excluded.monthly_price_krw,
currency = excluded.currency,
available = excluded.available`
).bind(
@@ -123,6 +135,8 @@ export class PricingRepository extends BaseRepository<Pricing> {
price.region_id,
price.hourly_price,
price.monthly_price,
hourlyKrw,
monthlyKrw,
price.currency,
price.available
);

View File

@@ -0,0 +1,162 @@
/**
* VPU Instances Repository
* Handles CRUD operations for VPU (NETINT Quadra) accelerated instance types
*/
import { BaseRepository } from './base';
import { VpuInstance, VpuInstanceInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
export class VpuInstancesRepository extends BaseRepository<VpuInstance> {
protected tableName = 'vpu_instances';
protected logger = createLogger('[VpuInstancesRepository]');
protected allowedColumns = [
'provider_id',
'instance_id',
'instance_name',
'vcpu',
'memory_mb',
'storage_gb',
'transfer_tb',
'network_speed_gbps',
'vpu_type',
'metadata',
];
/**
* Find all VPU instances for a specific provider
*/
async findByProvider(providerId: number): Promise<VpuInstance[]> {
try {
const result = await this.db
.prepare('SELECT * FROM vpu_instances WHERE provider_id = ?')
.bind(providerId)
.all<VpuInstance>();
return result.results;
} catch (error) {
this.logger.error('findByProvider failed', {
providerId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find VPU instances for provider: ${providerId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find VPU instance by provider and instance ID
*/
async findByInstanceId(providerId: number, instanceId: string): Promise<VpuInstance | null> {
try {
const result = await this.db
.prepare('SELECT * FROM vpu_instances WHERE provider_id = ? AND instance_id = ?')
.bind(providerId, instanceId)
.first<VpuInstance>();
return result || null;
} catch (error) {
this.logger.error('findByInstanceId failed', {
providerId,
instanceId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find VPU instance: ${instanceId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find VPU instances by VPU type
*/
async findByVpuType(vpuType: string): Promise<VpuInstance[]> {
try {
const result = await this.db
.prepare('SELECT * FROM vpu_instances WHERE vpu_type = ?')
.bind(vpuType)
.all<VpuInstance>();
return result.results;
} catch (error) {
this.logger.error('findByVpuType failed', {
vpuType,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find VPU instances by type: ${vpuType}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Upsert multiple VPU instances (batch operation)
*/
async upsertMany(providerId: number, instances: VpuInstanceInput[]): Promise<number> {
if (instances.length === 0) {
return 0;
}
try {
const statements = instances.map((instance) => {
return this.db.prepare(
`INSERT INTO vpu_instances (
provider_id, instance_id, instance_name, vcpu, memory_mb,
storage_gb, transfer_tb, network_speed_gbps, vpu_type, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider_id, instance_id)
DO UPDATE SET
instance_name = excluded.instance_name,
vcpu = excluded.vcpu,
memory_mb = excluded.memory_mb,
storage_gb = excluded.storage_gb,
transfer_tb = excluded.transfer_tb,
network_speed_gbps = excluded.network_speed_gbps,
vpu_type = excluded.vpu_type,
metadata = excluded.metadata,
updated_at = datetime('now')`
).bind(
providerId,
instance.instance_id,
instance.instance_name,
instance.vcpu,
instance.memory_mb,
instance.storage_gb,
instance.transfer_tb,
instance.network_speed_gbps,
instance.vpu_type,
instance.metadata
);
});
const results = await this.db.batch(statements);
const successCount = results.filter((r) => r.success).length;
this.logger.info('upsertMany completed', {
providerId,
total: instances.length,
success: successCount,
});
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
providerId,
count: instances.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to upsert VPU instances for provider: ${providerId}`,
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
}

View File

@@ -0,0 +1,138 @@
/**
* VPU Pricing Repository
* Handles CRUD operations for VPU instance pricing data
*/
import { BaseRepository } from './base';
import { VpuPricing, VpuPricingInput, RepositoryError, ErrorCodes, Env } from '../types';
import { createLogger } from '../utils/logger';
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
export class VpuPricingRepository extends BaseRepository<VpuPricing> {
protected tableName = 'vpu_pricing';
protected logger = createLogger('[VpuPricingRepository]');
protected allowedColumns = [
'vpu_instance_id',
'region_id',
'hourly_price',
'monthly_price',
'hourly_price_krw',
'monthly_price_krw',
'currency',
'available',
];
constructor(db: D1Database, private env?: Env) {
super(db);
}
/**
* Find all pricing records for a specific VPU instance
*/
async findByVpuInstance(vpuInstanceId: number): Promise<VpuPricing[]> {
try {
const result = await this.db
.prepare('SELECT * FROM vpu_pricing WHERE vpu_instance_id = ?')
.bind(vpuInstanceId)
.all<VpuPricing>();
return result.results;
} catch (error) {
this.logger.error('findByVpuInstance failed', {
vpuInstanceId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find pricing for VPU instance: ${vpuInstanceId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find pricing for a specific VPU instance in a specific region
*/
async findByInstanceAndRegion(vpuInstanceId: number, regionId: number): Promise<VpuPricing | null> {
try {
const result = await this.db
.prepare('SELECT * FROM vpu_pricing WHERE vpu_instance_id = ? AND region_id = ?')
.bind(vpuInstanceId, regionId)
.first<VpuPricing>();
return result || null;
} catch (error) {
this.logger.error('findByInstanceAndRegion failed', {
vpuInstanceId,
regionId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find pricing for VPU instance ${vpuInstanceId} in region ${regionId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Upsert multiple VPU pricing records (batch operation)
*/
async upsertMany(pricingData: VpuPricingInput[]): Promise<number> {
if (pricingData.length === 0) {
return 0;
}
try {
const statements = pricingData.map((pricing) => {
const hourlyKrw = calculateKRWHourly(pricing.hourly_price, this.env);
const monthlyKrw = calculateKRWMonthly(pricing.monthly_price, this.env);
return this.db.prepare(
`INSERT INTO vpu_pricing (
vpu_instance_id, region_id, hourly_price, monthly_price,
hourly_price_krw, monthly_price_krw, currency, available
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(vpu_instance_id, region_id)
DO UPDATE SET
hourly_price = excluded.hourly_price,
monthly_price = excluded.monthly_price,
hourly_price_krw = excluded.hourly_price_krw,
monthly_price_krw = excluded.monthly_price_krw,
currency = excluded.currency,
available = excluded.available,
updated_at = datetime('now')`
).bind(
pricing.vpu_instance_id,
pricing.region_id,
pricing.hourly_price,
pricing.monthly_price,
hourlyKrw,
monthlyKrw,
pricing.currency,
pricing.available
);
});
const results = await this.db.batch(statements);
const successCount = results.filter((r) => r.success).length;
this.logger.info('upsertMany completed', {
total: pricingData.length,
success: successCount,
});
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
count: pricingData.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to upsert VPU pricing records',
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
}

View File

@@ -64,6 +64,8 @@ interface RawQueryResult {
pricing_region_id: number | null;
hourly_price: number | null;
monthly_price: number | null;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string | null;
pricing_available: number | null;
pricing_created_at: string | null;
@@ -190,6 +192,8 @@ export class QueryService {
pr.region_id as pricing_region_id,
pr.hourly_price,
pr.monthly_price,
pr.hourly_price_krw,
pr.monthly_price_krw,
pr.currency,
pr.available as pricing_available,
pr.created_at as pricing_created_at,
@@ -376,6 +380,8 @@ export class QueryService {
region_id: row.pricing_region_id,
hourly_price: row.hourly_price,
monthly_price: row.monthly_price,
hourly_price_krw: row.hourly_price_krw,
monthly_price_krw: row.monthly_price_krw,
currency: row.currency,
available: row.pricing_available,
created_at: row.pricing_created_at,

View File

@@ -25,6 +25,8 @@ import type {
RegionInput,
InstanceTypeInput,
PricingInput,
GpuInstanceInput,
GpuPricingInput,
} from '../types';
import { SyncStage } from '../types';
@@ -46,17 +48,32 @@ export interface SyncConnectorAdapter {
/** Fetch all instance types (normalized) */
getInstanceTypes(): Promise<InstanceTypeInput[]>;
/** Fetch GPU instances (optional, only for providers with GPU support) */
getGpuInstances?(): Promise<GpuInstanceInput[]>;
/** Fetch G8 instances (optional, only for Linode) */
getG8Instances?(): Promise<any[]>;
/** Fetch VPU instances (optional, only for Linode) */
getVpuInstances?(): Promise<any[]>;
/**
* Fetch pricing data for instances and regions
* @param instanceTypeIds - Array of database instance type IDs
* @param regionIds - Array of database region IDs
* @param dbInstanceMap - Map of DB instance type ID to instance_id (API ID) for avoiding redundant queries
* @param dbGpuMap - Map of GPU instance IDs (optional)
* @param dbG8Map - Map of G8 instance IDs (optional)
* @param dbVpuMap - Map of VPU instance IDs (optional)
* @returns Array of pricing records OR number of records if batched internally
*/
getPricing(
instanceTypeIds: number[],
regionIds: number[],
dbInstanceMap: Map<number, { instance_id: string }>
dbInstanceMap: Map<number, { instance_id: string }>,
dbGpuMap?: Map<number, { instance_id: string }>,
dbG8Map?: Map<number, { instance_id: string }>,
dbVpuMap?: Map<number, { instance_id: string }>
): Promise<PricingInput[] | number>;
}
@@ -73,7 +90,7 @@ export class SyncOrchestrator {
private vault: VaultClient,
env?: Env
) {
this.repos = new RepositoryFactory(db);
this.repos = new RepositoryFactory(db, env);
this.env = env;
this.logger = createLogger('[SyncOrchestrator]', env);
this.logger.info('Initialized');
@@ -138,17 +155,79 @@ export class SyncOrchestrator {
providerRecord.id,
normalizedRegions
);
const instancesCount = await this.repos.instances.upsertMany(
// Persist regular instances (already filtered in getInstanceTypes)
const regularInstancesCount = await this.repos.instances.upsertMany(
providerRecord.id,
normalizedInstances
);
// Handle specialized instances separately for Linode and Vultr
let gpuInstancesCount = 0;
let g8InstancesCount = 0;
let vpuInstancesCount = 0;
if (provider.toLowerCase() === 'linode') {
// GPU instances
if ('getGpuInstances' in connector) {
const gpuInstances = await (connector as any).getGpuInstances();
if (gpuInstances && gpuInstances.length > 0) {
gpuInstancesCount = await this.repos.gpuInstances.upsertMany(
providerRecord.id,
gpuInstances
);
}
}
// G8 instances
if ('getG8Instances' in connector) {
const g8Instances = await (connector as any).getG8Instances();
if (g8Instances && g8Instances.length > 0) {
g8InstancesCount = await this.repos.g8Instances.upsertMany(
providerRecord.id,
g8Instances
);
}
}
// VPU instances
if ('getVpuInstances' in connector) {
const vpuInstances = await (connector as any).getVpuInstances();
if (vpuInstances && vpuInstances.length > 0) {
vpuInstancesCount = await this.repos.vpuInstances.upsertMany(
providerRecord.id,
vpuInstances
);
}
}
}
// Handle Vultr GPU instances
if (provider.toLowerCase() === 'vultr') {
if ('getGpuInstances' in connector) {
const gpuInstances = await (connector as any).getGpuInstances();
if (gpuInstances && gpuInstances.length > 0) {
gpuInstancesCount = await this.repos.gpuInstances.upsertMany(
providerRecord.id,
gpuInstances
);
}
}
}
const instancesCount = regularInstancesCount + gpuInstancesCount + g8InstancesCount + vpuInstancesCount;
// Fetch pricing data - need instance and region IDs from DB
// Use D1 batch to reduce query count from 2 to 1 (50% reduction in queries)
const [dbRegionsResult, dbInstancesResult] = await this.repos.db.batch([
// Use D1 batch to reduce query count (fetch all instance types in one batch)
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 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');
@@ -164,8 +243,27 @@ export class SyncOrchestrator {
dbInstancesData.map(i => [i.id, { instance_id: i.instance_id }])
);
// Create specialized instance mappings
const dbGpuMap = new Map(
(dbGpuResult.results as Array<{ id: number; instance_id: string }>).map(i => [i.id, { instance_id: i.instance_id }])
);
const dbG8Map = new Map(
(dbG8Result.results as Array<{ id: number; instance_id: string }>).map(i => [i.id, { instance_id: i.instance_id }])
);
const dbVpuMap = new Map(
(dbVpuResult.results as Array<{ id: number; instance_id: string }>).map(i => [i.id, { instance_id: i.instance_id }])
);
// Get pricing data - may return array or count depending on provider
const pricingResult = await connector.getPricing(instanceTypeIds, regionIds, dbInstanceMap);
// Pass all instance maps for specialized pricing
const pricingResult = await connector.getPricing(
instanceTypeIds,
regionIds,
dbInstanceMap,
dbGpuMap,
dbG8Map,
dbVpuMap
);
// Handle both return types: array (Linode, Vultr) or number (AWS with generator)
let pricingCount = 0;
@@ -177,7 +275,15 @@ export class SyncOrchestrator {
pricingCount = await this.repos.pricing.upsertMany(pricingResult);
}
this.logger.info(`${provider}${stage}`, { regions: regionsCount, instances: instancesCount, pricing: pricingCount });
this.logger.info(`${provider}${stage}`, {
regions: regionsCount,
regular_instances: regularInstancesCount,
gpu_instances: gpuInstancesCount,
g8_instances: g8InstancesCount,
vpu_instances: vpuInstancesCount,
total_instances: instancesCount,
pricing: pricingCount
});
// Stage 7: Validate
stage = SyncStage.VALIDATE;
@@ -497,6 +603,235 @@ export class SyncOrchestrator {
}
}
/**
* Generate Linode GPU pricing records in batches using Generator pattern
* Minimizes memory usage by yielding batches at a time (default: 100)
*
* @param gpuInstanceTypeIds - Array of database GPU instance type IDs
* @param regionIds - Array of database region IDs
* @param dbGpuInstanceMap - Map of GPU instance type ID to DB instance data
* @param rawInstanceMap - Map of instance_id (API ID) to raw Linode data
* @param env - Environment configuration for SYNC_BATCH_SIZE
* @yields Batches of GpuPricingInput records (configurable batch size)
*
* Manual Test:
* For typical Linode GPU instances (~10 GPU types × 20 regions = 200 records):
* - Default batch size (100): ~2 batches
* - Memory savings: ~50% (200 records → 100 records in memory)
* - Verify: Check logs for "Generated and upserted GPU pricing records for Linode"
*/
private *generateLinodeGpuPricingBatches(
gpuInstanceTypeIds: number[],
regionIds: number[],
dbGpuInstanceMap: Map<number, { instance_id: string }>,
rawInstanceMap: Map<string, { id: string; price: { hourly: number; monthly: number } }>,
env?: Env
): Generator<GpuPricingInput[], void, void> {
const BATCH_SIZE = parseInt(env?.SYNC_BATCH_SIZE || '100', 10);
let batch: GpuPricingInput[] = [];
for (const regionId of regionIds) {
for (const gpuInstanceId of gpuInstanceTypeIds) {
const dbInstance = dbGpuInstanceMap.get(gpuInstanceId);
if (!dbInstance) {
this.logger.warn('GPU instance type not found', { gpuInstanceId });
continue;
}
const rawInstance = rawInstanceMap.get(dbInstance.instance_id);
if (!rawInstance) {
this.logger.warn('Raw GPU instance data not found', { instance_id: dbInstance.instance_id });
continue;
}
batch.push({
gpu_instance_id: gpuInstanceId,
region_id: regionId,
hourly_price: rawInstance.price.hourly,
monthly_price: rawInstance.price.monthly,
currency: 'USD',
available: 1,
});
if (batch.length >= BATCH_SIZE) {
yield batch;
batch = [];
}
}
}
// Yield remaining records
if (batch.length > 0) {
yield batch;
}
}
/**
* Generate Vultr GPU pricing records in batches using Generator pattern
* Minimizes memory usage by yielding batches at a time (default: 100)
*
* @param gpuInstanceTypeIds - Array of database GPU instance type IDs
* @param regionIds - Array of database region IDs
* @param dbGpuInstanceMap - Map of GPU instance type ID to DB instance data
* @param rawPlanMap - Map of plan_id (API ID) to raw Vultr plan data
* @param env - Environment configuration for SYNC_BATCH_SIZE
* @yields Batches of GpuPricingInput records (configurable batch size)
*
* Manual Test:
* For typical Vultr GPU instances (~35 vcg types × 20 regions = 700 records):
* - Default batch size (100): ~7 batches
* - Memory savings: ~85% (700 records → 100 records in memory)
* - Verify: Check logs for "Generated and upserted GPU pricing records for Vultr"
*/
private *generateVultrGpuPricingBatches(
gpuInstanceTypeIds: number[],
regionIds: number[],
dbGpuInstanceMap: Map<number, { instance_id: string }>,
rawPlanMap: Map<string, { id: string; monthly_cost: number }>,
env?: Env
): Generator<GpuPricingInput[], void, void> {
const BATCH_SIZE = parseInt(env?.SYNC_BATCH_SIZE || '100', 10);
let batch: GpuPricingInput[] = [];
for (const regionId of regionIds) {
for (const gpuInstanceId of gpuInstanceTypeIds) {
const dbInstance = dbGpuInstanceMap.get(gpuInstanceId);
if (!dbInstance) {
this.logger.warn('GPU instance type not found', { gpuInstanceId });
continue;
}
const rawPlan = rawPlanMap.get(dbInstance.instance_id);
if (!rawPlan) {
this.logger.warn('Raw GPU plan data not found', { instance_id: dbInstance.instance_id });
continue;
}
// Calculate hourly price: monthly_cost / 730 hours
const hourlyPrice = rawPlan.monthly_cost / 730;
batch.push({
gpu_instance_id: gpuInstanceId,
region_id: regionId,
hourly_price: hourlyPrice,
monthly_price: rawPlan.monthly_cost,
currency: 'USD',
available: 1,
});
if (batch.length >= BATCH_SIZE) {
yield batch;
batch = [];
}
}
}
// Yield remaining records
if (batch.length > 0) {
yield batch;
}
}
/**
* Generate G8 pricing records in batches for Linode
* Similar to GPU pricing generator but for G8 instances
*/
private *generateLinodeG8PricingBatches(
g8InstanceTypeIds: number[],
regionIds: number[],
dbG8InstanceMap: Map<number, { instance_id: string }>,
rawInstanceMap: Map<string, { id: string; price: { hourly: number; monthly: number } }>,
env?: Env
): Generator<any[], void, void> {
const BATCH_SIZE = parseInt(env?.SYNC_BATCH_SIZE || '100', 10);
let batch: any[] = [];
for (const regionId of regionIds) {
for (const g8InstanceId of g8InstanceTypeIds) {
const dbInstance = dbG8InstanceMap.get(g8InstanceId);
if (!dbInstance) {
this.logger.warn('G8 instance type not found', { g8InstanceId });
continue;
}
const rawInstance = rawInstanceMap.get(dbInstance.instance_id);
if (!rawInstance) {
this.logger.warn('Raw G8 instance data not found', { instance_id: dbInstance.instance_id });
continue;
}
batch.push({
g8_instance_id: g8InstanceId,
region_id: regionId,
hourly_price: rawInstance.price.hourly,
monthly_price: rawInstance.price.monthly,
currency: 'USD',
available: 1,
});
if (batch.length >= BATCH_SIZE) {
yield batch;
batch = [];
}
}
}
// Yield remaining records
if (batch.length > 0) {
yield batch;
}
}
/**
* Generate VPU pricing records in batches for Linode
* Similar to GPU pricing generator but for VPU instances
*/
private *generateLinodeVpuPricingBatches(
vpuInstanceTypeIds: number[],
regionIds: number[],
dbVpuInstanceMap: Map<number, { instance_id: string }>,
rawInstanceMap: Map<string, { id: string; price: { hourly: number; monthly: number } }>,
env?: Env
): Generator<any[], void, void> {
const BATCH_SIZE = parseInt(env?.SYNC_BATCH_SIZE || '100', 10);
let batch: any[] = [];
for (const regionId of regionIds) {
for (const vpuInstanceId of vpuInstanceTypeIds) {
const dbInstance = dbVpuInstanceMap.get(vpuInstanceId);
if (!dbInstance) {
this.logger.warn('VPU instance type not found', { vpuInstanceId });
continue;
}
const rawInstance = rawInstanceMap.get(dbInstance.instance_id);
if (!rawInstance) {
this.logger.warn('Raw VPU instance data not found', { instance_id: dbInstance.instance_id });
continue;
}
batch.push({
vpu_instance_id: vpuInstanceId,
region_id: regionId,
hourly_price: rawInstance.price.hourly,
monthly_price: rawInstance.price.monthly,
currency: 'USD',
available: 1,
});
if (batch.length >= BATCH_SIZE) {
yield batch;
batch = [];
}
}
}
// Yield remaining records
if (batch.length > 0) {
yield batch;
}
}
/**
* Create connector for a specific provider
*
@@ -521,20 +856,73 @@ export class SyncOrchestrator {
getInstanceTypes: async () => {
const instances = await connector.fetchInstanceTypes();
cachedInstanceTypes = instances; // Cache for pricing
return instances.map(i => connector.normalizeInstance(i, providerId));
// Classification priority:
// 1. GPU (gpus > 0) → handled in getGpuInstances
// 2. VPU (id contains 'netint' or 'accelerated') → handled in getVpuInstances
// 3. G8 (id starts with 'g8-') → handled in getG8Instances
// 4. Default → regular instance_types
const regularInstances = instances.filter(i => {
if (i.gpus > 0) return false;
if (i.id.includes('netint') || i.id.includes('accelerated')) return false;
if (i.id.startsWith('g8-')) return false;
return true;
});
return regularInstances.map(i => connector.normalizeInstance(i, providerId));
},
getGpuInstances: async (): Promise<GpuInstanceInput[]> => {
// Use cached instances if available to avoid redundant API calls
if (!cachedInstanceTypes) {
this.logger.info('Fetching instance types for GPU extraction');
cachedInstanceTypes = await connector.fetchInstanceTypes();
}
// Filter and normalize GPU instances
const gpuInstances = cachedInstanceTypes.filter(i => i.gpus > 0);
return gpuInstances.map(i => connector.normalizeGpuInstance(i, providerId));
},
getG8Instances: async (): Promise<any[]> => {
// Use cached instances if available to avoid redundant API calls
if (!cachedInstanceTypes) {
this.logger.info('Fetching instance types for G8 extraction');
cachedInstanceTypes = await connector.fetchInstanceTypes();
}
// Filter and normalize G8 instances (g8- prefix)
const g8Instances = cachedInstanceTypes.filter(i =>
i.id.startsWith('g8-') && (!i.gpus || i.gpus === 0)
);
return g8Instances.map(i => connector.normalizeG8Instance(i, providerId));
},
getVpuInstances: async (): Promise<any[]> => {
// Use cached instances if available to avoid redundant API calls
if (!cachedInstanceTypes) {
this.logger.info('Fetching instance types for VPU extraction');
cachedInstanceTypes = await connector.fetchInstanceTypes();
}
// Filter and normalize VPU instances (netint or accelerated)
const vpuInstances = cachedInstanceTypes.filter(i =>
(i.id.includes('netint') || i.id.includes('accelerated')) && (!i.gpus || i.gpus === 0)
);
return vpuInstances.map(i => connector.normalizeVpuInstance(i, providerId));
},
getPricing: async (
instanceTypeIds: number[],
_instanceTypeIds: number[],
regionIds: number[],
dbInstanceMap: Map<number, { instance_id: string }>
dbInstanceMap: Map<number, { instance_id: string }>,
dbGpuMap?: Map<number, { instance_id: string }>,
dbG8Map?: Map<number, { instance_id: string }>,
dbVpuMap?: Map<number, { instance_id: string }>
): Promise<number> => {
/**
* Linode Pricing Extraction Strategy (Generator Pattern):
*
* Linode pricing is embedded in instance type data (price.hourly, price.monthly).
* Generate all region × instance combinations using generator pattern.
* GPU instances are separated and stored in gpu_pricing table.
*
* Expected volume: ~200 instances × 20 regions = ~4,000 pricing records
* Expected volume: ~190 regular + ~10 GPU instances × 20 regions = ~4,000 pricing records
* Generator pattern with 100 records/batch minimizes memory usage
* Each batch is immediately persisted to database to avoid memory buildup
*
@@ -542,9 +930,9 @@ export class SyncOrchestrator {
*
* Manual Test:
* 1. Run sync: curl -X POST http://localhost:8787/api/sync/linode
* 2. Verify pricing count: wrangler d1 execute cloud-instances-db --local --command "SELECT COUNT(*) FROM pricing WHERE instance_type_id IN (SELECT id FROM instance_types WHERE provider_id = (SELECT id FROM providers WHERE name = 'linode'))"
* 3. Sample pricing: wrangler d1 execute cloud-instances-db --local --command "SELECT p.*, i.instance_name, r.region_code FROM pricing p JOIN instance_types i ON p.instance_type_id = i.id JOIN regions r ON p.region_id = r.id WHERE i.provider_id = (SELECT id FROM providers WHERE name = 'linode') LIMIT 10"
* 4. Verify data integrity: wrangler d1 execute cloud-instances-db --local --command "SELECT COUNT(*) FROM pricing WHERE hourly_price = 0 OR monthly_price = 0"
* 2. Verify regular pricing: wrangler d1 execute cloud-instances-db --local --command "SELECT COUNT(*) FROM pricing WHERE instance_type_id IN (SELECT id FROM instance_types WHERE provider_id = (SELECT id FROM providers WHERE name = 'linode'))"
* 3. Verify GPU pricing: wrangler d1 execute cloud-instances-db --local --command "SELECT COUNT(*) FROM gpu_pricing WHERE gpu_instance_id IN (SELECT id FROM gpu_instances WHERE provider_id = (SELECT id FROM providers WHERE name = 'linode'))"
* 4. Sample pricing: wrangler d1 execute cloud-instances-db --local --command "SELECT p.*, i.instance_name, r.region_code FROM pricing p JOIN instance_types i ON p.instance_type_id = i.id JOIN regions r ON p.region_id = r.id WHERE i.provider_id = (SELECT id FROM providers WHERE name = 'linode') LIMIT 10"
*/
// Re-fetch instance types if not cached
@@ -558,23 +946,113 @@ export class SyncOrchestrator {
cachedInstanceTypes.map(i => [i.id, i])
);
// Use generator pattern for memory-efficient processing
const pricingGenerator = this.generateLinodePricingBatches(
instanceTypeIds,
regionIds,
dbInstanceMap,
rawInstanceMap,
this.env
);
// Use provided maps or create empty ones
const gpuMap = dbGpuMap || new Map();
const g8Map = dbG8Map || new Map();
const vpuMap = dbVpuMap || new Map();
// Process batches incrementally
let totalCount = 0;
for (const batch of pricingGenerator) {
const batchCount = await this.repos.pricing.upsertMany(batch);
totalCount += batchCount;
// Separate instances by type: GPU, VPU, G8, and regular
const gpuInstanceTypeIds: number[] = [];
const g8InstanceTypeIds: number[] = [];
const vpuInstanceTypeIds: number[] = [];
const regularInstanceTypeIds: number[] = [];
// Extract GPU instance IDs from gpuMap
for (const dbId of gpuMap.keys()) {
gpuInstanceTypeIds.push(dbId);
}
this.logger.info('Generated and upserted pricing records for Linode', { count: totalCount });
// Extract G8 instance IDs from g8Map
for (const dbId of g8Map.keys()) {
g8InstanceTypeIds.push(dbId);
}
// Extract VPU instance IDs from vpuMap
for (const dbId of vpuMap.keys()) {
vpuInstanceTypeIds.push(dbId);
}
// Regular instances from dbInstanceMap
for (const dbId of dbInstanceMap.keys()) {
regularInstanceTypeIds.push(dbId);
}
// Process regular instance pricing
let regularPricingCount = 0;
if (regularInstanceTypeIds.length > 0) {
const regularGenerator = this.generateLinodePricingBatches(
regularInstanceTypeIds,
regionIds,
dbInstanceMap,
rawInstanceMap,
this.env
);
for (const batch of regularGenerator) {
const batchCount = await this.repos.pricing.upsertMany(batch);
regularPricingCount += batchCount;
}
}
// Process GPU instance pricing
let gpuPricingCount = 0;
if (gpuInstanceTypeIds.length > 0) {
const gpuGenerator = this.generateLinodeGpuPricingBatches(
gpuInstanceTypeIds,
regionIds,
gpuMap,
rawInstanceMap,
this.env
);
for (const batch of gpuGenerator) {
const batchCount = await this.repos.gpuPricing.upsertMany(batch);
gpuPricingCount += batchCount;
}
}
// Process G8 instance pricing
let g8PricingCount = 0;
if (g8InstanceTypeIds.length > 0) {
const g8Generator = this.generateLinodeG8PricingBatches(
g8InstanceTypeIds,
regionIds,
g8Map,
rawInstanceMap,
this.env
);
for (const batch of g8Generator) {
const batchCount = await this.repos.g8Pricing.upsertMany(batch);
g8PricingCount += batchCount;
}
}
// Process VPU instance pricing
let vpuPricingCount = 0;
if (vpuInstanceTypeIds.length > 0) {
const vpuGenerator = this.generateLinodeVpuPricingBatches(
vpuInstanceTypeIds,
regionIds,
vpuMap,
rawInstanceMap,
this.env
);
for (const batch of vpuGenerator) {
const batchCount = await this.repos.vpuPricing.upsertMany(batch);
vpuPricingCount += batchCount;
}
}
const totalCount = regularPricingCount + gpuPricingCount + g8PricingCount + vpuPricingCount;
this.logger.info('Generated and upserted pricing records for Linode', {
regular_pricing: regularPricingCount,
gpu_pricing: gpuPricingCount,
g8_pricing: g8PricingCount,
vpu_pricing: vpuPricingCount,
total: totalCount
});
// Return total count of processed records
return totalCount;
@@ -596,12 +1074,27 @@ export class SyncOrchestrator {
getInstanceTypes: async () => {
const plans = await connector.fetchPlans();
cachedPlans = plans; // Cache for pricing
return plans.map(p => connector.normalizeInstance(p, providerId));
// Filter out GPU instances (vcg type)
const regularPlans = plans.filter(p => !p.id.startsWith('vcg'));
return regularPlans.map(p => connector.normalizeInstance(p, providerId));
},
getGpuInstances: async (): Promise<GpuInstanceInput[]> => {
// Use cached plans if available to avoid redundant API calls
if (!cachedPlans) {
this.logger.info('Fetching plans for GPU extraction');
cachedPlans = await connector.fetchPlans();
}
// Filter and normalize GPU instances (vcg type)
const gpuPlans = cachedPlans.filter(p => p.id.startsWith('vcg'));
return gpuPlans.map(p => connector.normalizeGpuInstance(p, providerId));
},
getPricing: async (
instanceTypeIds: number[],
regionIds: number[],
dbInstanceMap: Map<number, { instance_id: string }>
dbInstanceMap: Map<number, { instance_id: string }>,
dbGpuMap?: Map<number, { instance_id: string }>
): Promise<number> => {
/**
* Vultr Pricing Extraction Strategy (Generator Pattern):
@@ -609,17 +1102,19 @@ export class SyncOrchestrator {
* Vultr pricing is embedded in plan data (monthly_cost).
* Generate all region × plan combinations using generator pattern.
*
* Expected volume: ~100 plans × 20 regions = ~2,000 pricing records
* Expected volume: ~100 regular plans × 20 regions = ~2,000 pricing records
* ~35 GPU plans × 20 regions = ~700 GPU pricing records
* Generator pattern with 100 records/batch minimizes memory usage
* Each batch is immediately persisted to database to avoid memory buildup
*
* Memory savings: ~95% (2,000 records → 100 records in memory at a time)
* Memory savings: ~95% (2,700 records → 100 records in memory at a time)
*
* Manual Test:
* 1. Run sync: curl -X POST http://localhost:8787/api/sync/vultr
* 2. Verify pricing count: wrangler d1 execute cloud-instances-db --local --command "SELECT COUNT(*) FROM pricing WHERE instance_type_id IN (SELECT id FROM instance_types WHERE provider_id = (SELECT id FROM providers WHERE name = 'vultr'))"
* 3. Sample pricing: wrangler d1 execute cloud-instances-db --local --command "SELECT p.*, i.instance_name, r.region_code FROM pricing p JOIN instance_types i ON p.instance_type_id = i.id JOIN regions r ON p.region_id = r.id WHERE i.provider_id = (SELECT id FROM providers WHERE name = 'vultr') LIMIT 10"
* 4. Verify data integrity: wrangler d1 execute cloud-instances-db --local --command "SELECT COUNT(*) FROM pricing WHERE hourly_price = 0 OR monthly_price = 0"
* 3. Verify GPU pricing: wrangler d1 execute cloud-instances-db --local --command "SELECT COUNT(*) FROM gpu_pricing WHERE gpu_instance_id IN (SELECT id FROM gpu_instances WHERE provider_id = (SELECT id FROM providers WHERE name = 'vultr'))"
* 4. Sample pricing: wrangler d1 execute cloud-instances-db --local --command "SELECT p.*, i.instance_name, r.region_code FROM pricing p JOIN instance_types i ON p.instance_type_id = i.id JOIN regions r ON p.region_id = r.id WHERE i.provider_id = (SELECT id FROM providers WHERE name = 'vultr') LIMIT 10"
* 5. Verify data integrity: wrangler d1 execute cloud-instances-db --local --command "SELECT COUNT(*) FROM pricing WHERE hourly_price = 0 OR monthly_price = 0"
*/
// Re-fetch plans if not cached
@@ -633,23 +1128,48 @@ export class SyncOrchestrator {
cachedPlans.map(p => [p.id, p])
);
// Use generator pattern for memory-efficient processing
const pricingGenerator = this.generateVultrPricingBatches(
instanceTypeIds,
regionIds,
dbInstanceMap,
rawPlanMap,
this.env
);
// Process regular instance pricing
let regularPricingCount = 0;
if (instanceTypeIds.length > 0) {
const regularGenerator = this.generateVultrPricingBatches(
instanceTypeIds,
regionIds,
dbInstanceMap,
rawPlanMap,
this.env
);
// Process batches incrementally
let totalCount = 0;
for (const batch of pricingGenerator) {
const batchCount = await this.repos.pricing.upsertMany(batch);
totalCount += batchCount;
for (const batch of regularGenerator) {
const batchCount = await this.repos.pricing.upsertMany(batch);
regularPricingCount += batchCount;
}
}
this.logger.info('Generated and upserted pricing records for Vultr', { count: totalCount });
// Process GPU instance pricing
let gpuPricingCount = 0;
const gpuMap = dbGpuMap || new Map();
if (gpuMap.size > 0) {
const gpuInstanceTypeIds = Array.from(gpuMap.keys());
const gpuGenerator = this.generateVultrGpuPricingBatches(
gpuInstanceTypeIds,
regionIds,
gpuMap,
rawPlanMap,
this.env
);
for (const batch of gpuGenerator) {
const batchCount = await this.repos.gpuPricing.upsertMany(batch);
gpuPricingCount += batchCount;
}
}
const totalCount = regularPricingCount + gpuPricingCount;
this.logger.info('Generated and upserted pricing records for Vultr', {
regular_pricing: regularPricingCount,
gpu_pricing: gpuPricingCount,
total: totalCount
});
// Return total count of processed records
return totalCount;

View File

@@ -88,6 +88,8 @@ export interface Pricing {
region_id: number;
hourly_price: number;
monthly_price: number;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string;
available: number; // SQLite boolean (0/1)
created_at: string;
@@ -102,6 +104,97 @@ export interface PriceHistory {
recorded_at: string;
}
export interface GpuInstance {
id: number;
provider_id: number;
instance_id: string;
instance_name: string;
vcpu: number;
memory_mb: number;
storage_gb: number;
transfer_tb: number | null;
network_speed_gbps: number | null;
gpu_count: number;
gpu_type: string;
gpu_memory_gb: number | null;
metadata: string | null; // JSON string
created_at: string;
updated_at: string;
}
export interface GpuPricing {
id: number;
gpu_instance_id: number;
region_id: number;
hourly_price: number;
monthly_price: number;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string;
available: number; // SQLite boolean (0/1)
created_at: string;
updated_at: string;
}
export interface G8Instance {
id: number;
provider_id: number;
instance_id: string;
instance_name: string;
vcpu: number;
memory_mb: number;
storage_gb: number;
transfer_tb: number | null;
network_speed_gbps: number | null;
metadata: string | null; // JSON string
created_at: string;
updated_at: string;
}
export interface G8Pricing {
id: number;
g8_instance_id: number;
region_id: number;
hourly_price: number;
monthly_price: number;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string;
available: number; // SQLite boolean (0/1)
created_at: string;
updated_at: string;
}
export interface VpuInstance {
id: number;
provider_id: number;
instance_id: string;
instance_name: string;
vcpu: number;
memory_mb: number;
storage_gb: number;
transfer_tb: number | null;
network_speed_gbps: number | null;
vpu_type: string;
metadata: string | null; // JSON string
created_at: string;
updated_at: string;
}
export interface VpuPricing {
id: number;
vpu_instance_id: number;
region_id: number;
hourly_price: number;
monthly_price: number;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string;
available: number; // SQLite boolean (0/1)
created_at: string;
updated_at: string;
}
// ============================================================
// Repository Input Types (for create/update operations)
// ============================================================
@@ -109,7 +202,13 @@ export interface PriceHistory {
export type ProviderInput = Omit<Provider, 'id' | 'created_at' | 'updated_at'>;
export type RegionInput = Omit<Region, 'id' | 'created_at' | 'updated_at'>;
export type InstanceTypeInput = Omit<InstanceType, 'id' | 'created_at' | 'updated_at'>;
export type PricingInput = Omit<Pricing, 'id' | 'created_at' | 'updated_at'>;
export type PricingInput = Omit<Pricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw'>;
export type GpuInstanceInput = Omit<GpuInstance, 'id' | 'created_at' | 'updated_at'>;
export type GpuPricingInput = Omit<GpuPricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw'>;
export type G8InstanceInput = Omit<G8Instance, 'id' | 'created_at' | 'updated_at'>;
export type G8PricingInput = Omit<G8Pricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw'>;
export type VpuInstanceInput = Omit<VpuInstance, 'id' | 'created_at' | 'updated_at'>;
export type VpuPricingInput = Omit<VpuPricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw'>;
// ============================================================
// Error Types
@@ -353,6 +452,12 @@ export interface Env {
LOG_LEVEL?: string;
/** CORS origin for Access-Control-Allow-Origin header (default: '*') */
CORS_ORIGIN?: string;
/** KRW exchange rate (USD to KRW conversion, default: 1450) */
KRW_EXCHANGE_RATE?: string;
/** KRW VAT rate multiplier (default: 1.1 for 10% VAT) */
KRW_VAT_RATE?: string;
/** KRW markup rate multiplier (default: 1.1 for 10% markup) */
KRW_MARKUP_RATE?: string;
}
// ============================================================