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:
40
CLAUDE.md
40
CLAUDE.md
@@ -51,14 +51,19 @@ src/
|
|||||||
|
|
||||||
### D1 Database Tables (cloud-instances-db)
|
### D1 Database Tables (cloud-instances-db)
|
||||||
|
|
||||||
- `providers` - Cloud providers (50+)
|
**Primary tables (Anvil pricing)**:
|
||||||
- `instance_types` - Server specifications
|
- `anvil_instances` - Anvil server specifications (vcpus, memory_gb, disk_gb, etc.)
|
||||||
- `pricing` - Regional pricing
|
- `anvil_regions` - Anvil data center regions (name, display_name, country_code)
|
||||||
- `regions` - Geographic regions
|
- `anvil_pricing` - Anvil pricing data (monthly_price in USD)
|
||||||
|
|
||||||
|
**Support tables**:
|
||||||
- `tech_specs` - Resource requirements per technology (vcpu_per_users, min_memory_mb)
|
- `tech_specs` - Resource requirements per technology (vcpu_per_users, min_memory_mb)
|
||||||
- `vps_benchmarks` - Geekbench 6 benchmark data (269 records)
|
- `vps_benchmarks` - Geekbench 6 benchmark data (269 records)
|
||||||
- `benchmark_results` / `benchmark_types` / `processors` - Phoronix benchmark data
|
- `benchmark_results` / `benchmark_types` / `processors` - Phoronix benchmark data
|
||||||
|
|
||||||
|
**Legacy tables (no longer used)**:
|
||||||
|
- `providers`, `instance_types`, `pricing`, `regions` - Old Linode/Vultr data
|
||||||
|
|
||||||
## Key Implementation Details
|
## Key Implementation Details
|
||||||
|
|
||||||
### DB Workload Multiplier (`recommend.ts`)
|
### DB Workload Multiplier (`recommend.ts`)
|
||||||
@@ -90,20 +95,26 @@ Estimates monthly bandwidth based on use_case patterns:
|
|||||||
|
|
||||||
Heavy bandwidth (>1TB/month) prefers Linode for included bandwidth.
|
Heavy bandwidth (>1TB/month) prefers Linode for included bandwidth.
|
||||||
|
|
||||||
### Flexible Region Matching (`utils.ts`)
|
### Flexible Region Matching
|
||||||
|
|
||||||
Both `/api/recommend` and `/api/servers` use shared `buildFlexibleRegionConditions()`:
|
Both `/api/recommend` and `/api/servers` use `buildFlexibleRegionConditionsAnvil()` for anvil_regions:
|
||||||
```sql
|
```sql
|
||||||
LOWER(r.region_code) = ? OR
|
LOWER(ar.name) = ? OR
|
||||||
LOWER(r.region_code) LIKE ? OR
|
LOWER(ar.name) LIKE ? OR
|
||||||
LOWER(r.region_name) LIKE ? OR
|
LOWER(ar.display_name) LIKE ? OR
|
||||||
LOWER(r.country_code) = ?
|
LOWER(ar.country_code) = ?
|
||||||
```
|
```
|
||||||
|
|
||||||
Valid inputs: `"korea"`, `"KR"`, `"seoul"`, `"tokyo"`, `"japan"`, `"ap-northeast-2"`, `"icn"`
|
Valid inputs: `"korea"`, `"KR"`, `"seoul"`, `"tokyo"`, `"japan"`, `"ap-northeast-2"`, `"icn"`
|
||||||
|
|
||||||
Country names are auto-expanded via `COUNTRY_NAME_TO_REGIONS` mapping.
|
Country names are auto-expanded via `COUNTRY_NAME_TO_REGIONS` mapping.
|
||||||
|
|
||||||
|
### Exchange Rate Handling (`utils.ts`)
|
||||||
|
|
||||||
|
- Korean users (lang=ko) see prices in KRW
|
||||||
|
- Exchange rate fetched from open.er-api.com with 1-hour KV cache
|
||||||
|
- Fallback rate: 1450 KRW/USD if API unavailable
|
||||||
|
|
||||||
### AI Prompt Strategy (`recommend.ts`)
|
### AI Prompt Strategy (`recommend.ts`)
|
||||||
|
|
||||||
- Uses OpenAI GPT-4o-mini via Cloudflare AI Gateway (bypasses regional restrictions)
|
- Uses OpenAI GPT-4o-mini via Cloudflare AI Gateway (bypasses regional restrictions)
|
||||||
@@ -174,6 +185,13 @@ curl -s "https://server-recommend.kappa-d8e.workers.dev/api/servers?region=korea
|
|||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
|
### Anvil Pricing Migration (Latest)
|
||||||
|
- **New tables**: Migrated from `pricing` to `anvil_pricing` tables
|
||||||
|
- **Provider**: Now uses "Anvil" as single provider (previously Linode/Vultr)
|
||||||
|
- **Exchange rate**: Real-time USD→KRW conversion via open.er-api.com
|
||||||
|
- **Removed**: `provider_filter` parameter no longer supported
|
||||||
|
- **Currency handling**: Korean users see KRW, others see USD
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
- **Modular architecture**: Split from single 2370-line file into organized modules
|
- **Modular architecture**: Split from single 2370-line file into organized modules
|
||||||
- **Centralized config**: All magic numbers moved to `LIMITS` in config.ts
|
- **Centralized config**: All magic numbers moved to `LIMITS` in config.ts
|
||||||
@@ -197,6 +215,4 @@ curl -s "https://server-recommend.kappa-d8e.workers.dev/api/servers?region=korea
|
|||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
- **Dead code removed**: Unused `queryVPSBenchmarks` function deleted
|
- **Dead code removed**: Unused `queryVPSBenchmarks` function deleted
|
||||||
- **DRY**: `DEFAULT_REGION_FILTER_SQL` and `buildFlexibleRegionConditions()` shared between handlers
|
|
||||||
- **Name-based filtering**: Provider queries use names, not hardcoded IDs
|
|
||||||
- **Flexible region matching**: Both endpoints support country/city/code inputs (korea, seoul, icn)
|
- **Flexible region matching**: Both endpoints support country/city/code inputs (korea, seoul, icn)
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export const i18n: Record<string, {
|
|||||||
traffic_pattern: "(optional) 'steady' | 'spiky' | 'growing'",
|
traffic_pattern: "(optional) 'steady' | 'spiky' | 'growing'",
|
||||||
region_preference: "(optional) string[] - e.g. ['korea', 'japan']",
|
region_preference: "(optional) string[] - e.g. ['korea', 'japan']",
|
||||||
budget_limit: "(optional) number - max monthly USD",
|
budget_limit: "(optional) number - max monthly USD",
|
||||||
provider_filter: "(optional) string[] - e.g. ['linode', 'vultr']",
|
|
||||||
lang: "(optional) 'en' | 'zh' | 'ja' | 'ko' - response language"
|
lang: "(optional) 'en' | 'zh' | 'ja' | 'ko' - response language"
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
@@ -110,7 +109,6 @@ export const i18n: Record<string, {
|
|||||||
traffic_pattern: "(可选) 'steady' | 'spiky' | 'growing'",
|
traffic_pattern: "(可选) 'steady' | 'spiky' | 'growing'",
|
||||||
region_preference: "(可选) string[] - 例如 ['korea', 'japan']",
|
region_preference: "(可选) string[] - 例如 ['korea', 'japan']",
|
||||||
budget_limit: "(可选) number - 每月最高预算(美元)",
|
budget_limit: "(可选) number - 每月最高预算(美元)",
|
||||||
provider_filter: "(可选) string[] - 例如 ['linode', 'vultr']",
|
|
||||||
lang: "(可选) 'en' | 'zh' | 'ja' | 'ko' - 响应语言"
|
lang: "(可选) 'en' | 'zh' | 'ja' | 'ko' - 响应语言"
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
@@ -130,7 +128,6 @@ export const i18n: Record<string, {
|
|||||||
traffic_pattern: "(任意) 'steady' | 'spiky' | 'growing'",
|
traffic_pattern: "(任意) 'steady' | 'spiky' | 'growing'",
|
||||||
region_preference: "(任意) string[] - 例: ['korea', 'japan']",
|
region_preference: "(任意) string[] - 例: ['korea', 'japan']",
|
||||||
budget_limit: "(任意) number - 月額予算上限(USD)",
|
budget_limit: "(任意) number - 月額予算上限(USD)",
|
||||||
provider_filter: "(任意) string[] - 例: ['linode', 'vultr']",
|
|
||||||
lang: "(任意) 'en' | 'zh' | 'ja' | 'ko' - 応答言語"
|
lang: "(任意) 'en' | 'zh' | 'ja' | 'ko' - 応答言語"
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
@@ -150,7 +147,6 @@ export const i18n: Record<string, {
|
|||||||
traffic_pattern: "(선택) 'steady' | 'spiky' | 'growing'",
|
traffic_pattern: "(선택) 'steady' | 'spiky' | 'growing'",
|
||||||
region_preference: "(선택) string[] - 예: ['korea', 'japan']",
|
region_preference: "(선택) string[] - 예: ['korea', 'japan']",
|
||||||
budget_limit: "(선택) number - 월 예산 한도(원화, KRW)",
|
budget_limit: "(선택) number - 월 예산 한도(원화, KRW)",
|
||||||
provider_filter: "(선택) string[] - 예: ['linode', 'vultr']",
|
|
||||||
lang: "(선택) 'en' | 'zh' | 'ja' | 'ko' - 응답 언어"
|
lang: "(선택) 'en' | 'zh' | 'ja' | 'ko' - 응답 언어"
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ import {
|
|||||||
isValidTechSpec,
|
isValidTechSpec,
|
||||||
isValidAIRecommendation,
|
isValidAIRecommendation,
|
||||||
sanitizeForAIPrompt,
|
sanitizeForAIPrompt,
|
||||||
DEFAULT_REGION_FILTER_SQL,
|
getExchangeRate
|
||||||
buildFlexibleRegionConditions
|
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
export async function handleRecommend(
|
export async function handleRecommend(
|
||||||
@@ -77,7 +76,6 @@ export async function handleRecommend(
|
|||||||
traffic_pattern: body.traffic_pattern,
|
traffic_pattern: body.traffic_pattern,
|
||||||
has_region_pref: !!body.region_preference,
|
has_region_pref: !!body.region_preference,
|
||||||
has_budget: !!body.budget_limit,
|
has_budget: !!body.budget_limit,
|
||||||
has_provider_filter: !!body.provider_filter,
|
|
||||||
lang: lang,
|
lang: lang,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,7 +229,7 @@ export async function handleRecommend(
|
|||||||
|
|
||||||
// Phase 2: Query candidate servers and VPS benchmarks in parallel
|
// Phase 2: Query candidate servers and VPS benchmarks in parallel
|
||||||
const [candidates, vpsBenchmarks] = await Promise.all([
|
const [candidates, vpsBenchmarks] = await Promise.all([
|
||||||
queryCandidateServers(env.DB, body, minMemoryMb, minVcpu, bandwidthEstimate, lang),
|
queryCandidateServers(env.DB, env, body, minMemoryMb, minVcpu, bandwidthEstimate, lang),
|
||||||
queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
|
queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
console.warn('[Recommend] VPS benchmarks unavailable:', message);
|
console.warn('[Recommend] VPS benchmarks unavailable:', message);
|
||||||
@@ -312,104 +310,81 @@ export async function handleRecommend(
|
|||||||
}
|
}
|
||||||
async function queryCandidateServers(
|
async function queryCandidateServers(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
env: Env,
|
||||||
req: RecommendRequest,
|
req: RecommendRequest,
|
||||||
minMemoryMb?: number,
|
minMemoryMb?: number,
|
||||||
minVcpu?: number,
|
minVcpu?: number,
|
||||||
bandwidthEstimate?: BandwidthEstimate,
|
bandwidthEstimate?: BandwidthEstimate,
|
||||||
lang: string = 'en'
|
lang: string = 'en'
|
||||||
): Promise<Server[]> {
|
): Promise<Server[]> {
|
||||||
// Select price column based on language
|
// Get exchange rate for KRW display (Korean users)
|
||||||
// Korean → monthly_price_krw (KRW), Others → monthly_price_retail (1.21x USD)
|
const exchangeRate = lang === 'ko' ? await getExchangeRate(env) : 1;
|
||||||
const priceColumn = lang === 'ko' ? 'pr.monthly_price_krw' : 'pr.monthly_price_retail';
|
|
||||||
const currency = lang === 'ko' ? 'KRW' : 'USD';
|
const currency = lang === 'ko' ? 'KRW' : 'USD';
|
||||||
|
|
||||||
// Check if region preference is specified
|
// Build query using anvil_* tables
|
||||||
const hasRegionPref = req.region_preference && req.region_preference.length > 0;
|
// anvil_pricing.monthly_price is stored in USD
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
it.id,
|
ai.id,
|
||||||
p.display_name as provider_name,
|
'Anvil' as provider_name,
|
||||||
it.instance_id,
|
ai.name as instance_id,
|
||||||
it.instance_name,
|
ai.display_name as instance_name,
|
||||||
it.vcpu,
|
ai.vcpus as vcpu,
|
||||||
it.memory_mb,
|
CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb,
|
||||||
ROUND(it.memory_mb / 1024.0, 1) as memory_gb,
|
ai.memory_gb,
|
||||||
it.storage_gb,
|
ai.disk_gb as storage_gb,
|
||||||
it.network_speed_gbps,
|
ai.network_gbps as network_speed_gbps,
|
||||||
it.instance_family,
|
ai.category as instance_family,
|
||||||
it.gpu_count,
|
CASE WHEN ai.gpu_model IS NOT NULL THEN 1 ELSE 0 END as gpu_count,
|
||||||
it.gpu_type,
|
ai.gpu_model as gpu_type,
|
||||||
MIN(${priceColumn}) as monthly_price,
|
ap.monthly_price as monthly_price_usd,
|
||||||
r.region_name as region_name,
|
ar.display_name as region_name,
|
||||||
r.region_code as region_code,
|
ar.name as region_code,
|
||||||
r.country_code as country_code
|
ar.country_code
|
||||||
FROM instance_types it
|
FROM anvil_instances ai
|
||||||
JOIN providers p ON it.provider_id = p.id
|
JOIN anvil_pricing ap ON ap.anvil_instance_id = ai.id
|
||||||
JOIN pricing pr ON pr.instance_type_id = it.id
|
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
|
||||||
JOIN regions r ON pr.region_id = r.id
|
WHERE ai.active = 1 AND ar.active = 1
|
||||||
WHERE LOWER(p.name) IN ('linode', 'vultr') -- Linode, Vultr only
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: (string | number)[] = [];
|
const params: (string | number)[] = [];
|
||||||
|
|
||||||
|
// Filter by budget limit (convert to USD for comparison)
|
||||||
if (req.budget_limit) {
|
if (req.budget_limit) {
|
||||||
// Use same price column as display for budget filtering
|
const budgetUsd = lang === 'ko' ? req.budget_limit / exchangeRate : req.budget_limit;
|
||||||
query += ` AND ${priceColumn} <= ?`;
|
query += ` AND ap.monthly_price <= ?`;
|
||||||
params.push(req.budget_limit);
|
params.push(budgetUsd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by minimum memory requirement (from tech specs)
|
// Filter by minimum memory requirement (from tech specs)
|
||||||
|
// Note: anvil_instances uses memory_gb, so convert minMemoryMb to GB
|
||||||
if (minMemoryMb && minMemoryMb > 0) {
|
if (minMemoryMb && minMemoryMb > 0) {
|
||||||
query += ` AND it.memory_mb >= ?`;
|
const minMemoryGb = minMemoryMb / 1024;
|
||||||
params.push(minMemoryMb);
|
query += ` AND ai.memory_gb >= ?`;
|
||||||
|
params.push(minMemoryGb);
|
||||||
console.log(`[Candidates] Filtering by minimum memory: ${minMemoryMb}MB (${(minMemoryMb/1024).toFixed(1)}GB)`);
|
console.log(`[Candidates] Filtering by minimum memory: ${minMemoryMb}MB (${(minMemoryMb/1024).toFixed(1)}GB)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by minimum vCPU requirement (from expected users + tech specs)
|
// Filter by minimum vCPU requirement (from expected users + tech specs)
|
||||||
if (minVcpu && minVcpu > 0) {
|
if (minVcpu && minVcpu > 0) {
|
||||||
query += ` AND it.vcpu >= ?`;
|
query += ` AND ai.vcpus >= ?`;
|
||||||
params.push(minVcpu);
|
params.push(minVcpu);
|
||||||
console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`);
|
console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider preference based on bandwidth requirements (no hard filtering to avoid empty results)
|
// Flexible region matching using anvil_regions table
|
||||||
// Heavy/Very heavy bandwidth → Prefer Linode (better bandwidth allowance), but allow all providers
|
// r.* aliases need to change to ar.* for anvil_regions
|
||||||
// AI prompt will warn about bandwidth costs for non-Linode providers
|
|
||||||
if (bandwidthEstimate) {
|
|
||||||
if (bandwidthEstimate.category === 'very_heavy') {
|
|
||||||
// >6TB/month: Strongly prefer Linode, but don't exclude others (Linode may not be available in all regions)
|
|
||||||
console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode strongly preferred, all providers included`);
|
|
||||||
} else if (bandwidthEstimate.category === 'heavy') {
|
|
||||||
// 2-6TB/month: Prefer Linode
|
|
||||||
console.log(`[Candidates] Heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode preferred`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flexible region matching: region_code, region_name, or country_code
|
|
||||||
if (req.region_preference && req.region_preference.length > 0) {
|
if (req.region_preference && req.region_preference.length > 0) {
|
||||||
const { conditions, params: regionParams } = buildFlexibleRegionConditions(req.region_preference);
|
const { conditions, params: regionParams } = buildFlexibleRegionConditionsAnvil(req.region_preference);
|
||||||
query += ` AND (${conditions.join(' OR ')})`;
|
query += ` AND (${conditions.join(' OR ')})`;
|
||||||
params.push(...regionParams);
|
params.push(...regionParams);
|
||||||
} else {
|
} else {
|
||||||
// No region specified → default to Seoul/Tokyo/Osaka/Singapore
|
// No region specified → default to Seoul/Tokyo/Singapore
|
||||||
query += ` AND ${DEFAULT_REGION_FILTER_SQL}`;
|
query += ` AND ${DEFAULT_ANVIL_REGION_FILTER_SQL}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by provider if specified
|
// Order by price
|
||||||
if (req.provider_filter && req.provider_filter.length > 0) {
|
query += ` ORDER BY ap.monthly_price ASC LIMIT 50`;
|
||||||
const placeholders = req.provider_filter.map(() => '?').join(',');
|
|
||||||
query += ` AND (p.name IN (${placeholders}) OR p.display_name IN (${placeholders}))`;
|
|
||||||
params.push(...req.provider_filter, ...req.provider_filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by instance + region to show each server per region
|
|
||||||
// For heavy/very_heavy bandwidth, prioritize Linode due to generous bandwidth allowance
|
|
||||||
const isHighBandwidth = bandwidthEstimate?.category === 'heavy' || bandwidthEstimate?.category === 'very_heavy';
|
|
||||||
const orderByClause = isHighBandwidth
|
|
||||||
? `ORDER BY CASE WHEN LOWER(p.name) = 'linode' THEN 0 ELSE 1 END, monthly_price ASC`
|
|
||||||
: `ORDER BY monthly_price ASC`;
|
|
||||||
query += ` GROUP BY it.id, r.id ${orderByClause} LIMIT 50`;
|
|
||||||
|
|
||||||
const result = await db.prepare(query).bind(...params).all();
|
const result = await db.prepare(query).bind(...params).all();
|
||||||
|
|
||||||
@@ -417,13 +392,22 @@ async function queryCandidateServers(
|
|||||||
throw new Error('Failed to query candidate servers');
|
throw new Error('Failed to query candidate servers');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add currency to each result and validate with type guard
|
// Convert USD prices to display currency and validate
|
||||||
const serversWithCurrency = (result.results as unknown[]).map(server => {
|
const serversWithCurrency = (result.results as unknown[]).map(server => {
|
||||||
if (typeof server === 'object' && server !== null) {
|
if (typeof server === 'object' && server !== null) {
|
||||||
return { ...server, currency };
|
const s = server as Record<string, unknown>;
|
||||||
|
const priceUsd = s.monthly_price_usd as number;
|
||||||
|
// Convert to KRW if Korean, otherwise keep USD
|
||||||
|
const displayPrice = lang === 'ko' ? Math.round(priceUsd * exchangeRate) : priceUsd;
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
monthly_price: displayPrice,
|
||||||
|
currency
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return server;
|
return server;
|
||||||
});
|
});
|
||||||
|
|
||||||
const validServers = serversWithCurrency.filter(isValidServer);
|
const validServers = serversWithCurrency.filter(isValidServer);
|
||||||
const invalidCount = result.results.length - validServers.length;
|
const invalidCount = result.results.length - validServers.length;
|
||||||
if (invalidCount > 0) {
|
if (invalidCount > 0) {
|
||||||
@@ -432,6 +416,80 @@ async function queryCandidateServers(
|
|||||||
return validServers;
|
return validServers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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%'
|
||||||
|
)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)[] = [];
|
||||||
|
|
||||||
|
// Country name to region mapping
|
||||||
|
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'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeLikePattern = (pattern: string): string => {
|
||||||
|
return pattern.replace(/[%_\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query relevant benchmark data for tech stack
|
* Query relevant benchmark data for tech stack
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,10 +3,90 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Env } from '../types';
|
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
|
* GET /api/servers - Server list with filtering
|
||||||
|
* Uses anvil_* tables for pricing data
|
||||||
*/
|
*/
|
||||||
export async function handleGetServers(
|
export async function handleGetServers(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -15,57 +95,49 @@ export async function handleGetServers(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const provider = url.searchParams.get('provider');
|
|
||||||
const minCpu = url.searchParams.get('minCpu');
|
const minCpu = url.searchParams.get('minCpu');
|
||||||
const minMemory = url.searchParams.get('minMemory');
|
const minMemory = url.searchParams.get('minMemory');
|
||||||
const region = url.searchParams.get('region');
|
const region = url.searchParams.get('region');
|
||||||
|
|
||||||
console.log('[GetServers] Query params:', {
|
console.log('[GetServers] Query params:', {
|
||||||
provider,
|
|
||||||
minCpu,
|
minCpu,
|
||||||
minMemory,
|
minMemory,
|
||||||
region,
|
region,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build SQL query dynamically
|
// Build SQL query using anvil_* tables
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
it.id,
|
ai.id,
|
||||||
p.display_name as provider_name,
|
'Anvil' as provider_name,
|
||||||
it.instance_id,
|
ai.name as instance_id,
|
||||||
it.instance_name,
|
ai.display_name as instance_name,
|
||||||
it.vcpu,
|
ai.vcpus as vcpu,
|
||||||
it.memory_mb,
|
CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb,
|
||||||
ROUND(it.memory_mb / 1024.0, 1) as memory_gb,
|
ai.memory_gb,
|
||||||
it.storage_gb,
|
ai.disk_gb as storage_gb,
|
||||||
it.network_speed_gbps,
|
ai.network_gbps as network_speed_gbps,
|
||||||
it.instance_family,
|
ai.category as instance_family,
|
||||||
it.gpu_count,
|
CASE WHEN ai.gpu_model IS NOT NULL THEN 1 ELSE 0 END as gpu_count,
|
||||||
it.gpu_type,
|
ai.gpu_model as gpu_type,
|
||||||
pr.monthly_price,
|
ap.monthly_price,
|
||||||
r.region_name,
|
ar.display_name as region_name,
|
||||||
r.region_code
|
ar.name as region_code
|
||||||
FROM instance_types it
|
FROM anvil_instances ai
|
||||||
JOIN providers p ON it.provider_id = p.id
|
JOIN anvil_pricing ap ON ap.anvil_instance_id = ai.id
|
||||||
JOIN pricing pr ON pr.instance_type_id = it.id
|
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
|
||||||
JOIN regions r ON pr.region_id = r.id
|
WHERE ai.active = 1 AND ar.active = 1
|
||||||
WHERE LOWER(p.name) IN ('linode', 'vultr')
|
AND ${DEFAULT_ANVIL_REGION_FILTER_SQL}
|
||||||
AND ${DEFAULT_REGION_FILTER_SQL}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: (string | number)[] = [];
|
const params: (string | number)[] = [];
|
||||||
|
|
||||||
if (provider) {
|
|
||||||
query += ` AND p.name = ?`;
|
|
||||||
params.push(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minCpu) {
|
if (minCpu) {
|
||||||
const parsedCpu = parseInt(minCpu, 10);
|
const parsedCpu = parseInt(minCpu, 10);
|
||||||
if (isNaN(parsedCpu)) {
|
if (isNaN(parsedCpu)) {
|
||||||
return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders);
|
return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
query += ` AND it.vcpu >= ?`;
|
query += ` AND ai.vcpus >= ?`;
|
||||||
params.push(parsedCpu);
|
params.push(parsedCpu);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,18 +146,19 @@ export async function handleGetServers(
|
|||||||
if (isNaN(parsedMemory)) {
|
if (isNaN(parsedMemory)) {
|
||||||
return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders);
|
return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
query += ` AND it.memory_mb >= ?`;
|
// minMemory is in GB, anvil_instances stores memory_gb
|
||||||
params.push(parsedMemory * 1024);
|
query += ` AND ai.memory_gb >= ?`;
|
||||||
|
params.push(parsedMemory);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (region) {
|
if (region) {
|
||||||
// Flexible region matching: supports country names, codes, city names
|
// 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 ')})`;
|
query += ` AND (${conditions.join(' OR ')})`;
|
||||||
params.push(...regionParams);
|
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();
|
const result = await env.DB.prepare(query).bind(...params).all();
|
||||||
|
|
||||||
@@ -93,8 +166,15 @@ export async function handleGetServers(
|
|||||||
throw new Error('Database query failed');
|
throw new Error('Database query failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate each result with type guard
|
// Add USD currency to each result and validate with type guard
|
||||||
const servers = (result.results as unknown[]).filter(isValidServer);
|
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;
|
const invalidCount = result.results.length - servers.length;
|
||||||
if (invalidCount > 0) {
|
if (invalidCount > 0) {
|
||||||
console.warn(`[GetServers] Filtered out ${invalidCount} invalid server records`);
|
console.warn(`[GetServers] Filtered out ${invalidCount} invalid server records`);
|
||||||
@@ -106,7 +186,7 @@ export async function handleGetServers(
|
|||||||
{
|
{
|
||||||
servers,
|
servers,
|
||||||
count: servers.length,
|
count: servers.length,
|
||||||
filters: { provider, minCpu, minMemory, region },
|
filters: { minCpu, minMemory, region },
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
corsHeaders
|
corsHeaders
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ export interface RecommendRequest {
|
|||||||
traffic_pattern?: 'steady' | 'spiky' | 'growing';
|
traffic_pattern?: 'steady' | 'spiky' | 'growing';
|
||||||
region_preference?: string[];
|
region_preference?: string[];
|
||||||
budget_limit?: number;
|
budget_limit?: number;
|
||||||
provider_filter?: string[]; // Filter by specific providers (e.g., ["Linode", "Vultr"])
|
|
||||||
lang?: 'en' | 'zh' | 'ja' | 'ko'; // Response language
|
lang?: 'en' | 'zh' | 'ja' | 'ko'; // Response language
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExchangeRateCache {
|
||||||
|
rate: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Server {
|
export interface Server {
|
||||||
id: number;
|
id: number;
|
||||||
provider_name: string;
|
provider_name: string;
|
||||||
|
|||||||
88
src/utils.ts
88
src/utils.ts
@@ -12,7 +12,9 @@ import type {
|
|||||||
AIRecommendationResponse,
|
AIRecommendationResponse,
|
||||||
UseCaseConfig,
|
UseCaseConfig,
|
||||||
BandwidthEstimate,
|
BandwidthEstimate,
|
||||||
BandwidthInfo
|
BandwidthInfo,
|
||||||
|
Env,
|
||||||
|
ExchangeRateCache
|
||||||
} from './types';
|
} from './types';
|
||||||
import { USE_CASE_CONFIGS, i18n, LIMITS } from './config';
|
import { USE_CASE_CONFIGS, i18n, LIMITS } from './config';
|
||||||
|
|
||||||
@@ -96,12 +98,6 @@ export function generateCacheKey(req: RecommendRequest): string {
|
|||||||
parts.push(`budget:${req.budget_limit}`);
|
parts.push(`budget:${req.budget_limit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.provider_filter && req.provider_filter.length > 0) {
|
|
||||||
const sortedProviders = [...req.provider_filter].sort();
|
|
||||||
const sanitizedProviders = sortedProviders.map(sanitizeCacheValue).join(',');
|
|
||||||
parts.push(`prov:${sanitizedProviders}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include language in cache key
|
// Include language in cache key
|
||||||
if (req.lang) {
|
if (req.lang) {
|
||||||
parts.push(`lang:${req.lang}`);
|
parts.push(`lang:${req.lang}`);
|
||||||
@@ -331,16 +327,6 @@ export function validateRecommendRequest(body: any, lang: string = 'en'): Valida
|
|||||||
invalidFields.push({ field: 'budget_limit', reason: 'must be a non-negative number' });
|
invalidFields.push({ field: 'budget_limit', reason: 'must be a non-negative number' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.provider_filter !== undefined) {
|
|
||||||
if (!Array.isArray(body.provider_filter)) {
|
|
||||||
invalidFields.push({ field: 'provider_filter', reason: 'must be an array' });
|
|
||||||
} else if (body.provider_filter.length > 10) {
|
|
||||||
invalidFields.push({ field: 'provider_filter', reason: 'must not exceed 10 items' });
|
|
||||||
} else if (!body.provider_filter.every((item: any) => typeof item === 'string')) {
|
|
||||||
invalidFields.push({ field: 'provider_filter', reason: 'all items must be strings' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate lang field if provided
|
// Validate lang field if provided
|
||||||
if (body.lang !== undefined && !['en', 'zh', 'ja', 'ko'].includes(body.lang)) {
|
if (body.lang !== undefined && !['en', 'zh', 'ja', 'ko'].includes(body.lang)) {
|
||||||
invalidFields.push({ field: 'lang', reason: "must be one of: 'en', 'zh', 'ja', 'ko'" });
|
invalidFields.push({ field: 'lang', reason: "must be one of: 'en', 'zh', 'ja', 'ko'" });
|
||||||
@@ -684,6 +670,74 @@ export function sanitizeForAIPrompt(input: string, maxLength: number = 200): str
|
|||||||
return sanitized.slice(0, maxLength);
|
return sanitized.slice(0, maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange rate constants
|
||||||
|
*/
|
||||||
|
const EXCHANGE_RATE_CACHE_KEY = 'exchange_rate:USD_KRW';
|
||||||
|
const EXCHANGE_RATE_TTL_SECONDS = 3600; // 1 hour
|
||||||
|
const EXCHANGE_RATE_FALLBACK = 1450; // Fallback KRW rate if API fails
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get USD to KRW exchange rate with KV caching
|
||||||
|
* Uses open.er-api.com free API
|
||||||
|
*/
|
||||||
|
export async function getExchangeRate(env: Env): Promise<number> {
|
||||||
|
// Try to get cached rate from KV
|
||||||
|
if (env.CACHE) {
|
||||||
|
try {
|
||||||
|
const cached = await env.CACHE.get(EXCHANGE_RATE_CACHE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached) as ExchangeRateCache;
|
||||||
|
console.log(`[ExchangeRate] Using cached rate: ${data.rate}`);
|
||||||
|
return data.rate;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ExchangeRate] Cache read error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh rate from API
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://open.er-api.com/v6/latest/USD', {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { rates?: { KRW?: number } };
|
||||||
|
const rate = data?.rates?.KRW;
|
||||||
|
|
||||||
|
if (!rate || typeof rate !== 'number' || rate < 1000 || rate > 2000) {
|
||||||
|
console.warn('[ExchangeRate] Invalid rate from API:', rate);
|
||||||
|
return EXCHANGE_RATE_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ExchangeRate] Fetched fresh rate: ${rate}`);
|
||||||
|
|
||||||
|
// Cache the rate
|
||||||
|
if (env.CACHE) {
|
||||||
|
try {
|
||||||
|
const cacheData: ExchangeRateCache = {
|
||||||
|
rate,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
await env.CACHE.put(EXCHANGE_RATE_CACHE_KEY, JSON.stringify(cacheData), {
|
||||||
|
expirationTtl: EXCHANGE_RATE_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ExchangeRate] Cache write error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rate;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ExchangeRate] API error:', error);
|
||||||
|
return EXCHANGE_RATE_FALLBACK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// In-memory fallback for rate limiting when CACHE KV is unavailable
|
// In-memory fallback for rate limiting when CACHE KV is unavailable
|
||||||
const inMemoryRateLimit = new Map<string, { count: number; resetTime: number }>();
|
const inMemoryRateLimit = new Map<string, { count: number; resetTime: number }>();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user