feat: migrate pricing from legacy tables to anvil_pricing
- Replace pricing/instance_types/providers/regions with anvil_* tables - Add real-time USD→KRW exchange rate conversion (open.er-api.com) - Korean users (lang=ko) see KRW prices, others see USD - Remove provider_filter parameter (now single provider: Anvil) - Add ExchangeRateCache interface with 1-hour KV caching - Update CLAUDE.md with new table structure and exchange rate docs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,90 @@
|
||||
*/
|
||||
|
||||
import type { Env } from '../types';
|
||||
import { jsonResponse, isValidServer, DEFAULT_REGION_FILTER_SQL, buildFlexibleRegionConditions } from '../utils';
|
||||
import { jsonResponse, isValidServer } from '../utils';
|
||||
|
||||
/**
|
||||
* Default region filter SQL for anvil_regions (when no region is specified)
|
||||
*/
|
||||
const DEFAULT_ANVIL_REGION_FILTER_SQL = `(
|
||||
-- Korea (Seoul)
|
||||
ar.name IN ('icn', 'ap-northeast-2') OR
|
||||
LOWER(ar.display_name) LIKE '%seoul%' OR
|
||||
-- Japan (Tokyo, Osaka)
|
||||
ar.name IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
||||
LOWER(ar.name) LIKE '%tyo%' OR
|
||||
LOWER(ar.name) LIKE '%osa%' OR
|
||||
LOWER(ar.display_name) LIKE '%tokyo%' OR
|
||||
LOWER(ar.display_name) LIKE '%osaka%' OR
|
||||
-- Singapore
|
||||
ar.name IN ('sgp', 'ap-southeast-1') OR
|
||||
LOWER(ar.name) LIKE '%sin%' OR
|
||||
LOWER(ar.name) LIKE '%sgp%' OR
|
||||
LOWER(ar.display_name) LIKE '%singapore%'
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Country name to region code/city mapping for flexible region matching
|
||||
*/
|
||||
const COUNTRY_NAME_TO_REGIONS: Record<string, string[]> = {
|
||||
'korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
||||
'south korea': ['seoul', 'koreacentral', 'kr', 'ap-northeast-2'],
|
||||
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
|
||||
'singapore': ['singapore', 'ap-southeast-1'],
|
||||
'indonesia': ['jakarta', 'ap-southeast-3'],
|
||||
'australia': ['sydney', 'melbourne', 'au-east', 'ap-southeast-2'],
|
||||
'india': ['mumbai', 'delhi', 'chennai', 'ap-south'],
|
||||
'usa': ['us-east', 'us-west', 'us-central', 'america'],
|
||||
'us': ['us-east', 'us-west', 'us-central', 'america'],
|
||||
'united states': ['us-east', 'us-west', 'us-central', 'america'],
|
||||
'uk': ['london', 'uk-south', 'eu-west-2'],
|
||||
'united kingdom': ['london', 'uk-south', 'eu-west-2'],
|
||||
'germany': ['frankfurt', 'eu-central', 'eu-west-3'],
|
||||
'france': ['paris', 'eu-west-3'],
|
||||
'netherlands': ['amsterdam', 'eu-west-1'],
|
||||
'brazil': ['sao paulo', 'sa-east'],
|
||||
'canada': ['toronto', 'montreal', 'ca-central'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape LIKE pattern special characters
|
||||
*/
|
||||
function escapeLikePattern(pattern: string): string {
|
||||
return pattern.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build flexible region matching SQL conditions for anvil_regions
|
||||
*/
|
||||
function buildFlexibleRegionConditionsAnvil(
|
||||
regions: string[]
|
||||
): { conditions: string[]; params: (string | number)[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
for (const region of regions) {
|
||||
const lowerRegion = region.toLowerCase();
|
||||
const expandedRegions = COUNTRY_NAME_TO_REGIONS[lowerRegion] || [lowerRegion];
|
||||
const allRegions = [lowerRegion, ...expandedRegions];
|
||||
|
||||
for (const r of allRegions) {
|
||||
const escapedRegion = escapeLikePattern(r);
|
||||
conditions.push(`(
|
||||
LOWER(ar.name) = ? OR
|
||||
LOWER(ar.name) LIKE ? ESCAPE '\\' OR
|
||||
LOWER(ar.display_name) LIKE ? ESCAPE '\\' OR
|
||||
LOWER(ar.country_code) = ?
|
||||
)`);
|
||||
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
||||
}
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/servers - Server list with filtering
|
||||
* Uses anvil_* tables for pricing data
|
||||
*/
|
||||
export async function handleGetServers(
|
||||
request: Request,
|
||||
@@ -15,57 +95,49 @@ export async function handleGetServers(
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const provider = url.searchParams.get('provider');
|
||||
const minCpu = url.searchParams.get('minCpu');
|
||||
const minMemory = url.searchParams.get('minMemory');
|
||||
const region = url.searchParams.get('region');
|
||||
|
||||
console.log('[GetServers] Query params:', {
|
||||
provider,
|
||||
minCpu,
|
||||
minMemory,
|
||||
region,
|
||||
});
|
||||
|
||||
// Build SQL query dynamically
|
||||
// Build SQL query using anvil_* tables
|
||||
let query = `
|
||||
SELECT
|
||||
it.id,
|
||||
p.display_name as provider_name,
|
||||
it.instance_id,
|
||||
it.instance_name,
|
||||
it.vcpu,
|
||||
it.memory_mb,
|
||||
ROUND(it.memory_mb / 1024.0, 1) as memory_gb,
|
||||
it.storage_gb,
|
||||
it.network_speed_gbps,
|
||||
it.instance_family,
|
||||
it.gpu_count,
|
||||
it.gpu_type,
|
||||
pr.monthly_price,
|
||||
r.region_name,
|
||||
r.region_code
|
||||
FROM instance_types it
|
||||
JOIN providers p ON it.provider_id = p.id
|
||||
JOIN pricing pr ON pr.instance_type_id = it.id
|
||||
JOIN regions r ON pr.region_id = r.id
|
||||
WHERE LOWER(p.name) IN ('linode', 'vultr')
|
||||
AND ${DEFAULT_REGION_FILTER_SQL}
|
||||
ai.id,
|
||||
'Anvil' as provider_name,
|
||||
ai.name as instance_id,
|
||||
ai.display_name as instance_name,
|
||||
ai.vcpus as vcpu,
|
||||
CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb,
|
||||
ai.memory_gb,
|
||||
ai.disk_gb as storage_gb,
|
||||
ai.network_gbps as network_speed_gbps,
|
||||
ai.category as instance_family,
|
||||
CASE WHEN ai.gpu_model IS NOT NULL THEN 1 ELSE 0 END as gpu_count,
|
||||
ai.gpu_model as gpu_type,
|
||||
ap.monthly_price,
|
||||
ar.display_name as region_name,
|
||||
ar.name as region_code
|
||||
FROM anvil_instances ai
|
||||
JOIN anvil_pricing ap ON ap.anvil_instance_id = ai.id
|
||||
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
|
||||
WHERE ai.active = 1 AND ar.active = 1
|
||||
AND ${DEFAULT_ANVIL_REGION_FILTER_SQL}
|
||||
`;
|
||||
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
if (provider) {
|
||||
query += ` AND p.name = ?`;
|
||||
params.push(provider);
|
||||
}
|
||||
|
||||
if (minCpu) {
|
||||
const parsedCpu = parseInt(minCpu, 10);
|
||||
if (isNaN(parsedCpu)) {
|
||||
return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders);
|
||||
}
|
||||
query += ` AND it.vcpu >= ?`;
|
||||
query += ` AND ai.vcpus >= ?`;
|
||||
params.push(parsedCpu);
|
||||
}
|
||||
|
||||
@@ -74,18 +146,19 @@ export async function handleGetServers(
|
||||
if (isNaN(parsedMemory)) {
|
||||
return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders);
|
||||
}
|
||||
query += ` AND it.memory_mb >= ?`;
|
||||
params.push(parsedMemory * 1024);
|
||||
// minMemory is in GB, anvil_instances stores memory_gb
|
||||
query += ` AND ai.memory_gb >= ?`;
|
||||
params.push(parsedMemory);
|
||||
}
|
||||
|
||||
if (region) {
|
||||
// Flexible region matching: supports country names, codes, city names
|
||||
const { conditions, params: regionParams } = buildFlexibleRegionConditions([region]);
|
||||
const { conditions, params: regionParams } = buildFlexibleRegionConditionsAnvil([region]);
|
||||
query += ` AND (${conditions.join(' OR ')})`;
|
||||
params.push(...regionParams);
|
||||
}
|
||||
|
||||
query += ` GROUP BY it.id, r.id ORDER BY pr.monthly_price ASC LIMIT 100`;
|
||||
query += ` ORDER BY ap.monthly_price ASC LIMIT 100`;
|
||||
|
||||
const result = await env.DB.prepare(query).bind(...params).all();
|
||||
|
||||
@@ -93,8 +166,15 @@ export async function handleGetServers(
|
||||
throw new Error('Database query failed');
|
||||
}
|
||||
|
||||
// Validate each result with type guard
|
||||
const servers = (result.results as unknown[]).filter(isValidServer);
|
||||
// Add USD currency to each result and validate with type guard
|
||||
const serversWithCurrency = (result.results as unknown[]).map(server => {
|
||||
if (typeof server === 'object' && server !== null) {
|
||||
return { ...server, currency: 'USD' };
|
||||
}
|
||||
return server;
|
||||
});
|
||||
|
||||
const servers = serversWithCurrency.filter(isValidServer);
|
||||
const invalidCount = result.results.length - servers.length;
|
||||
if (invalidCount > 0) {
|
||||
console.warn(`[GetServers] Filtered out ${invalidCount} invalid server records`);
|
||||
@@ -106,7 +186,7 @@ export async function handleGetServers(
|
||||
{
|
||||
servers,
|
||||
count: servers.length,
|
||||
filters: { provider, minCpu, minMemory, region },
|
||||
filters: { minCpu, minMemory, region },
|
||||
},
|
||||
200,
|
||||
corsHeaders
|
||||
|
||||
Reference in New Issue
Block a user