refactor: simplify pricing tables to USD-only
- Remove KRW pricing calculations from all pricing tables - Simplify pricing table to store only wholesale USD prices - Simplify anvil_pricing to store only retail USD prices - Remove KRW environment variables (KRW_EXCHANGE_RATE, KRW_MARGIN_RATE) - Remove KRW functions from constants.ts - Update GPU/G8/VPU pricing repositories to match - Add Anvil tables and repositories for branded product support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
250
ANVIL_IMPLEMENTATION.md
Normal file
250
ANVIL_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Anvil Product Tables Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
Implemented complete database schema and repositories for Anvil-branded cloud products, including regions, instances, pricing, and transfer pricing.
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### 1. Database Migration (004_anvil_tables.sql)
|
||||
|
||||
Created 4 new tables with proper indexes, triggers, and foreign key constraints:
|
||||
|
||||
#### anvil_regions
|
||||
- Maps Anvil-branded regions to source provider regions
|
||||
- Contains 6 initial regions (Tokyo 1-3, Osaka 1-2, Seoul 1)
|
||||
- Links to existing `regions` table via `source_region_id`
|
||||
|
||||
**Columns:**
|
||||
- `id`, `name` (unique), `display_name`, `country_code`
|
||||
- `source_provider`, `source_region_code`, `source_region_id` (FK)
|
||||
- `active`, `created_at`, `updated_at`
|
||||
|
||||
**Initial Data:**
|
||||
```
|
||||
anvil-tyo1 → Linode ap-northeast (region_id: 26)
|
||||
anvil-tyo2 → Linode jp-tyo-3 (region_id: 3)
|
||||
anvil-tyo3 → Vultr nrt (region_id: 323)
|
||||
anvil-osa1 → Linode jp-osa (region_id: 13)
|
||||
anvil-osa2 → Vultr itm (region_id: 314)
|
||||
anvil-sel1 → Vultr icn (region_id: 313)
|
||||
```
|
||||
|
||||
#### anvil_instances
|
||||
- Defines Anvil product specifications
|
||||
- Supports categories: vm, gpu, g8, vpu
|
||||
- GPU-specific fields: `gpu_model`, `gpu_vram_gb`
|
||||
|
||||
**Columns:**
|
||||
- `id`, `name` (unique), `display_name`, `category`
|
||||
- `vcpus`, `memory_gb`, `disk_gb`
|
||||
- `transfer_tb`, `network_gbps`
|
||||
- `gpu_model`, `gpu_vram_gb` (nullable)
|
||||
- `active`, `created_at`, `updated_at`
|
||||
|
||||
#### anvil_pricing
|
||||
- Retail pricing with cost tracking
|
||||
- Auto-calculates KRW prices using exchange rates
|
||||
- Links to both Anvil instances/regions and source instances
|
||||
|
||||
**Columns:**
|
||||
- `id`, `anvil_instance_id` (FK), `anvil_region_id` (FK)
|
||||
- `hourly_price`, `monthly_price` (retail USD)
|
||||
- `hourly_price_krw`, `monthly_price_krw` (retail KRW)
|
||||
- `cost_hourly`, `cost_monthly` (wholesale USD)
|
||||
- `source_instance_id` (optional FK to source tables)
|
||||
- `active`, `created_at`, `updated_at`
|
||||
- UNIQUE constraint on (anvil_instance_id, anvil_region_id)
|
||||
|
||||
#### anvil_transfer_pricing
|
||||
- Data transfer pricing per region
|
||||
- KRW conversion support
|
||||
|
||||
**Columns:**
|
||||
- `id`, `anvil_region_id` (FK)
|
||||
- `price_per_gb` (USD), `price_per_gb_krw` (KRW)
|
||||
- `included_tb` (reference only)
|
||||
- `active`, `created_at`, `updated_at`
|
||||
- UNIQUE constraint on anvil_region_id
|
||||
|
||||
### 2. TypeScript Types (src/types.ts)
|
||||
|
||||
Added 8 new type definitions:
|
||||
|
||||
```typescript
|
||||
AnvilRegion
|
||||
AnvilInstance
|
||||
AnvilPricing
|
||||
AnvilTransferPricing
|
||||
AnvilRegionInput
|
||||
AnvilInstanceInput
|
||||
AnvilPricingInput
|
||||
AnvilTransferPricingInput
|
||||
```
|
||||
|
||||
All Input types auto-derive from their base types, excluding `id`, `created_at`, and `updated_at`.
|
||||
|
||||
### 3. Repositories
|
||||
|
||||
Created 4 repository classes following existing BaseRepository pattern:
|
||||
|
||||
#### AnvilRegionsRepository
|
||||
- `findByName(name: string)` - Find by Anvil region name
|
||||
- `findByCountry(countryCode: string)` - Get all regions in a country
|
||||
- `findBySourceRegion(sourceRegionId: number)` - Reverse lookup
|
||||
- `findActive()` - Get all active regions
|
||||
- `updateActive(id: number, active: boolean)` - Toggle active status
|
||||
- `upsertMany(regions: AnvilRegionInput[])` - Bulk insert/update
|
||||
|
||||
#### AnvilInstancesRepository
|
||||
- `findByName(name: string)` - Find by instance name
|
||||
- `findByCategory(category)` - Filter by vm/gpu/g8/vpu
|
||||
- `findActive(category?)` - Get active instances with optional category filter
|
||||
- `searchByResources(minVcpus?, minMemoryGb?, minDiskGb?, category?)` - Resource-based search
|
||||
- `updateActive(id: number, active: boolean)` - Toggle active status
|
||||
- `upsertMany(instances: AnvilInstanceInput[])` - Bulk insert/update
|
||||
|
||||
#### AnvilPricingRepository
|
||||
- `findByInstance(anvilInstanceId: number)` - All pricing for an instance
|
||||
- `findByRegion(anvilRegionId: number)` - All pricing in a region
|
||||
- `findByInstanceAndRegion(instanceId, regionId)` - Specific pricing record
|
||||
- `findActive(instanceId?, regionId?)` - Active pricing with optional filters
|
||||
- `searchByPriceRange(minHourly?, maxHourly?, minMonthly?, maxMonthly?)` - Price range search
|
||||
- `updateActive(id: number, active: boolean)` - Toggle active status
|
||||
- `upsertMany(pricing: AnvilPricingInput[])` - Bulk insert/update with auto KRW calculation
|
||||
|
||||
#### AnvilTransferPricingRepository
|
||||
- `findByRegion(anvilRegionId: number)` - Get transfer pricing for a region
|
||||
- `findActive()` - Get all active transfer pricing
|
||||
- `updateActive(id: number, active: boolean)` - Toggle active status
|
||||
- `upsertMany(pricing: AnvilTransferPricingInput[])` - Bulk insert/update with KRW conversion
|
||||
|
||||
### 4. Repository Factory Updates
|
||||
|
||||
Updated `RepositoryFactory` class to include:
|
||||
- `anvilRegions: AnvilRegionsRepository`
|
||||
- `anvilInstances: AnvilInstancesRepository`
|
||||
- `anvilPricing: AnvilPricingRepository`
|
||||
- `anvilTransferPricing: AnvilTransferPricingRepository`
|
||||
|
||||
All repositories use lazy singleton pattern for efficient caching.
|
||||
|
||||
### 5. KRW Pricing Support
|
||||
|
||||
Pricing repositories automatically calculate KRW prices using environment variables:
|
||||
- `KRW_EXCHANGE_RATE` - Base USD to KRW conversion rate (default: 1450)
|
||||
- `KRW_VAT_RATE` - VAT multiplier (default: 1.1 for 10% VAT)
|
||||
- `KRW_MARKUP_RATE` - Markup multiplier (default: 1.1 for 10% markup)
|
||||
|
||||
KRW prices are auto-calculated during `upsertMany()` operations.
|
||||
|
||||
## Database Schema
|
||||
|
||||
```
|
||||
anvil_regions (1) ──< anvil_pricing (M)
|
||||
anvil_instances (1) ──< anvil_pricing (M)
|
||||
anvil_regions (1) ──< anvil_transfer_pricing (1)
|
||||
regions (1) ──< anvil_regions (M) [source mapping]
|
||||
```
|
||||
|
||||
## Deployment Status
|
||||
|
||||
✅ Migration 004 applied to remote database (2026-01-25)
|
||||
✅ 17 queries executed successfully
|
||||
✅ 6 initial regions inserted
|
||||
✅ All 4 tables created with indexes and triggers
|
||||
✅ TypeScript compilation successful
|
||||
✅ Deployed to production (Version: 5905e5df-7265-4872-b05e-e3fe4b7d0619)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Query Anvil Regions
|
||||
```typescript
|
||||
const repos = new RepositoryFactory(env.DB, env);
|
||||
|
||||
// Get all Japan regions
|
||||
const jpRegions = await repos.anvilRegions.findByCountry('JP');
|
||||
// Returns: anvil-tyo1, anvil-tyo2, anvil-tyo3, anvil-osa1, anvil-osa2
|
||||
|
||||
// Find specific region
|
||||
const tyo1 = await repos.anvilRegions.findByName('anvil-tyo1');
|
||||
```
|
||||
|
||||
### Create Anvil Instance
|
||||
```typescript
|
||||
await repos.anvilInstances.create({
|
||||
name: 'anvil-1g-1c',
|
||||
display_name: 'Basic 1GB',
|
||||
category: 'vm',
|
||||
vcpus: 1,
|
||||
memory_gb: 1,
|
||||
disk_gb: 25,
|
||||
transfer_tb: 1,
|
||||
network_gbps: 1,
|
||||
gpu_model: null,
|
||||
gpu_vram_gb: null,
|
||||
active: 1,
|
||||
});
|
||||
```
|
||||
|
||||
### Set Pricing (Auto KRW Calculation)
|
||||
```typescript
|
||||
await repos.anvilPricing.upsertMany([
|
||||
{
|
||||
anvil_instance_id: 1,
|
||||
anvil_region_id: 1,
|
||||
hourly_price: 0.015,
|
||||
monthly_price: 10,
|
||||
cost_hourly: 0.012,
|
||||
cost_monthly: 8,
|
||||
source_instance_id: 123,
|
||||
active: 1,
|
||||
}
|
||||
]);
|
||||
// KRW prices auto-calculated:
|
||||
// hourly_price_krw = 0.015 × 1450 × 1.1 × 1.1 ≈ 26.37
|
||||
// monthly_price_krw = 10 × 1450 × 1.1 × 1.1 ≈ 17,545
|
||||
```
|
||||
|
||||
### Search by Resources
|
||||
```typescript
|
||||
// Find instances with at least 2 vCPUs and 4GB RAM
|
||||
const instances = await repos.anvilInstances.searchByResources(
|
||||
2, // minVcpus
|
||||
4, // minMemoryGb
|
||||
null, // minDiskGb
|
||||
'vm' // category
|
||||
);
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
### Created
|
||||
- `/migrations/004_anvil_tables.sql` - Database schema
|
||||
- `/src/repositories/anvil-regions.ts` - Regions repository
|
||||
- `/src/repositories/anvil-instances.ts` - Instances repository
|
||||
- `/src/repositories/anvil-pricing.ts` - Pricing repository
|
||||
- `/src/repositories/anvil-transfer-pricing.ts` - Transfer pricing repository
|
||||
- `/src/repositories/anvil-regions.test.ts` - Unit tests
|
||||
|
||||
### Modified
|
||||
- `/src/types.ts` - Added 8 new types
|
||||
- `/src/repositories/index.ts` - Added 4 new repository exports and factory getters
|
||||
|
||||
## Next Steps
|
||||
|
||||
To complete the Anvil product implementation:
|
||||
|
||||
1. **Populate Instance Data**: Create Anvil instance definitions (VM, GPU, G8, VPU tiers)
|
||||
2. **Set Pricing**: Map Anvil instances to source providers and set retail pricing
|
||||
3. **API Endpoints**: Create endpoints for querying Anvil products
|
||||
4. **Sync Service**: Implement sync logic to update costs from source providers
|
||||
5. **Documentation**: Add API documentation for Anvil endpoints
|
||||
|
||||
## Testing
|
||||
|
||||
Basic repository tests created in `anvil-regions.test.ts`. Additional test coverage recommended for:
|
||||
- Instance search and filtering
|
||||
- Pricing calculations and KRW conversion
|
||||
- Transfer pricing queries
|
||||
- Edge cases and error handling
|
||||
60
migrations/003_add_retail_pricing.sql
Normal file
60
migrations/003_add_retail_pricing.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Migration 003: Add Retail Pricing Fields
|
||||
-- Description: Add hourly_price_retail and monthly_price_retail to all pricing tables
|
||||
-- Date: 2026-01-23
|
||||
-- Author: Claude Code
|
||||
|
||||
-- ============================================================
|
||||
-- Add retail pricing columns to pricing table
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE pricing ADD COLUMN hourly_price_retail REAL;
|
||||
ALTER TABLE pricing ADD COLUMN monthly_price_retail REAL;
|
||||
|
||||
-- Backfill existing data with retail prices (wholesale × 1.21)
|
||||
UPDATE pricing
|
||||
SET
|
||||
hourly_price_retail = ROUND(hourly_price * 1.21 * 10000) / 10000,
|
||||
monthly_price_retail = ROUND(monthly_price * 1.21 * 100) / 100
|
||||
WHERE hourly_price_retail IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Add retail pricing columns to gpu_pricing table
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE gpu_pricing ADD COLUMN hourly_price_retail REAL;
|
||||
ALTER TABLE gpu_pricing ADD COLUMN monthly_price_retail REAL;
|
||||
|
||||
-- Backfill existing data with retail prices (wholesale × 1.21)
|
||||
UPDATE gpu_pricing
|
||||
SET
|
||||
hourly_price_retail = ROUND(hourly_price * 1.21 * 10000) / 10000,
|
||||
monthly_price_retail = ROUND(monthly_price * 1.21 * 100) / 100
|
||||
WHERE hourly_price_retail IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Add retail pricing columns to g8_pricing table
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE g8_pricing ADD COLUMN hourly_price_retail REAL;
|
||||
ALTER TABLE g8_pricing ADD COLUMN monthly_price_retail REAL;
|
||||
|
||||
-- Backfill existing data with retail prices (wholesale × 1.21)
|
||||
UPDATE g8_pricing
|
||||
SET
|
||||
hourly_price_retail = ROUND(hourly_price * 1.21 * 10000) / 10000,
|
||||
monthly_price_retail = ROUND(monthly_price * 1.21 * 100) / 100
|
||||
WHERE hourly_price_retail IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Add retail pricing columns to vpu_pricing table
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE vpu_pricing ADD COLUMN hourly_price_retail REAL;
|
||||
ALTER TABLE vpu_pricing ADD COLUMN monthly_price_retail REAL;
|
||||
|
||||
-- Backfill existing data with retail prices (wholesale × 1.21)
|
||||
UPDATE vpu_pricing
|
||||
SET
|
||||
hourly_price_retail = ROUND(hourly_price * 1.21 * 10000) / 10000,
|
||||
monthly_price_retail = ROUND(monthly_price * 1.21 * 100) / 100
|
||||
WHERE hourly_price_retail IS NULL;
|
||||
161
migrations/004_anvil_tables.sql
Normal file
161
migrations/004_anvil_tables.sql
Normal file
@@ -0,0 +1,161 @@
|
||||
-- Migration 004: Anvil Product Tables
|
||||
-- Creates tables for Anvil-branded cloud products
|
||||
|
||||
-- ============================================================
|
||||
-- Table: anvil_regions
|
||||
-- Purpose: Anvil-branded regional datacenters mapped to source providers
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS anvil_regions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE, -- "anvil-tyo1", "anvil-sel1"
|
||||
display_name TEXT NOT NULL, -- "Tokyo 1", "Seoul 1"
|
||||
country_code TEXT NOT NULL, -- "JP", "KR"
|
||||
source_provider TEXT NOT NULL, -- "linode", "vultr"
|
||||
source_region_code TEXT NOT NULL, -- "jp-tyo-3", "nrt"
|
||||
source_region_id INTEGER NOT NULL, -- FK to regions.id
|
||||
active INTEGER NOT NULL DEFAULT 1, -- 0/1 boolean
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (source_region_id) REFERENCES regions(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
-- Index for fast lookup by source region
|
||||
CREATE INDEX IF NOT EXISTS idx_anvil_regions_source
|
||||
ON anvil_regions(source_region_id);
|
||||
|
||||
-- Index for active regions lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_anvil_regions_active
|
||||
ON anvil_regions(active);
|
||||
|
||||
-- ============================================================
|
||||
-- Table: anvil_instances
|
||||
-- Purpose: Anvil-branded instance specifications
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS anvil_instances (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE, -- "anvil-1g-1c"
|
||||
display_name TEXT NOT NULL, -- "Basic 1GB"
|
||||
category TEXT NOT NULL DEFAULT 'vm', -- "vm", "gpu", "g8", "vpu"
|
||||
vcpus INTEGER NOT NULL,
|
||||
memory_gb REAL NOT NULL,
|
||||
disk_gb INTEGER NOT NULL,
|
||||
transfer_tb REAL,
|
||||
network_gbps REAL,
|
||||
gpu_model TEXT, -- GPU-specific
|
||||
gpu_vram_gb INTEGER, -- GPU-specific
|
||||
active INTEGER NOT NULL DEFAULT 1, -- 0/1 boolean
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for category-based queries
|
||||
CREATE INDEX IF NOT EXISTS idx_anvil_instances_category
|
||||
ON anvil_instances(category);
|
||||
|
||||
-- Index for active instances lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_anvil_instances_active
|
||||
ON anvil_instances(active);
|
||||
|
||||
-- ============================================================
|
||||
-- Table: anvil_pricing
|
||||
-- Purpose: Anvil retail pricing with cost tracking
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS anvil_pricing (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
anvil_instance_id INTEGER NOT NULL,
|
||||
anvil_region_id INTEGER NOT NULL,
|
||||
hourly_price REAL NOT NULL, -- Retail price (USD)
|
||||
monthly_price REAL NOT NULL, -- Retail price (USD)
|
||||
hourly_price_krw REAL, -- Retail price (KRW)
|
||||
monthly_price_krw REAL, -- Retail price (KRW)
|
||||
cost_hourly REAL, -- Wholesale cost (USD)
|
||||
cost_monthly REAL, -- Wholesale cost (USD)
|
||||
source_instance_id INTEGER, -- FK to source tables (instance_types, gpu_instances, etc)
|
||||
active INTEGER NOT NULL DEFAULT 1, -- 0/1 boolean
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (anvil_instance_id) REFERENCES anvil_instances(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (anvil_region_id) REFERENCES anvil_regions(id) ON DELETE CASCADE,
|
||||
UNIQUE(anvil_instance_id, anvil_region_id)
|
||||
);
|
||||
|
||||
-- Index for instance-based queries
|
||||
CREATE INDEX IF NOT EXISTS idx_anvil_pricing_instance
|
||||
ON anvil_pricing(anvil_instance_id);
|
||||
|
||||
-- Index for region-based queries
|
||||
CREATE INDEX IF NOT EXISTS idx_anvil_pricing_region
|
||||
ON anvil_pricing(anvil_region_id);
|
||||
|
||||
-- Index for active pricing lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_anvil_pricing_active
|
||||
ON anvil_pricing(active);
|
||||
|
||||
-- ============================================================
|
||||
-- Table: anvil_transfer_pricing
|
||||
-- Purpose: Data transfer pricing per region
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS anvil_transfer_pricing (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
anvil_region_id INTEGER NOT NULL,
|
||||
price_per_gb REAL NOT NULL, -- USD/GB
|
||||
price_per_gb_krw REAL, -- KRW/GB
|
||||
included_tb REAL NOT NULL DEFAULT 0, -- Reference only
|
||||
active INTEGER NOT NULL DEFAULT 1, -- 0/1 boolean
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (anvil_region_id) REFERENCES anvil_regions(id) ON DELETE CASCADE,
|
||||
UNIQUE(anvil_region_id)
|
||||
);
|
||||
|
||||
-- Index for active transfer pricing lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_anvil_transfer_pricing_active
|
||||
ON anvil_transfer_pricing(active);
|
||||
|
||||
-- ============================================================
|
||||
-- Triggers: Auto-update updated_at timestamps
|
||||
-- ============================================================
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_anvil_regions_updated_at
|
||||
AFTER UPDATE ON anvil_regions
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE anvil_regions SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_anvil_instances_updated_at
|
||||
AFTER UPDATE ON anvil_instances
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE anvil_instances SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_anvil_pricing_updated_at
|
||||
AFTER UPDATE ON anvil_pricing
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE anvil_pricing SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trigger_anvil_transfer_pricing_updated_at
|
||||
AFTER UPDATE ON anvil_transfer_pricing
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE anvil_transfer_pricing SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- ============================================================
|
||||
-- Initial Data: anvil_regions
|
||||
-- ============================================================
|
||||
INSERT INTO anvil_regions (name, display_name, country_code, source_provider, source_region_code, source_region_id) VALUES
|
||||
('anvil-tyo1', 'Tokyo 1', 'JP', 'linode', 'ap-northeast', 26),
|
||||
('anvil-tyo2', 'Tokyo 2', 'JP', 'linode', 'jp-tyo-3', 3),
|
||||
('anvil-tyo3', 'Tokyo 3', 'JP', 'vultr', 'nrt', 323),
|
||||
('anvil-osa1', 'Osaka 1', 'JP', 'linode', 'jp-osa', 13),
|
||||
('anvil-osa2', 'Osaka 2', 'JP', 'vultr', 'itm', 314),
|
||||
('anvil-sel1', 'Seoul 1', 'KR', 'vultr', 'icn', 313)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
country_code = excluded.country_code,
|
||||
source_provider = excluded.source_provider,
|
||||
source_region_code = excluded.source_region_code,
|
||||
source_region_id = excluded.source_region_id;
|
||||
76
package-lock.json
generated
76
package-lock.json
generated
@@ -25,14 +25,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/unenv-preset": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.10.0.tgz",
|
||||
"integrity": "sha512-/uII4vLQXhzCAZzEVeYAjFLBNg2nqTJ1JGzd2lRF6ItYe6U2zVoYGfeKpGx/EkBF6euiU+cyBXgMdtJih+nQ6g==",
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.11.0.tgz",
|
||||
"integrity": "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"workerd": "^1.20251221.0"
|
||||
"workerd": "^1.20260115.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"workerd": {
|
||||
@@ -41,9 +41,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-darwin-64": {
|
||||
"version": "1.20260116.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260116.0.tgz",
|
||||
"integrity": "sha512-0LF2jR/5bfCIMYsqtCXHqaZRlXEMgnz4NzG/8KVmHROlKb06SJezYYoNKw+7s6ji4fgi1BcYAJBmWbC4nzMbqw==",
|
||||
"version": "1.20260120.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260120.0.tgz",
|
||||
"integrity": "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -58,9 +58,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-darwin-arm64": {
|
||||
"version": "1.20260116.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260116.0.tgz",
|
||||
"integrity": "sha512-a9OHts4jMoOkPedc4CnuHPeo9XRG3VCMMgr0ER5HtSfEDRQhh7MwIuPEmqI27KKrYj+DeoCazIgbp3gW9bFTAg==",
|
||||
"version": "1.20260120.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260120.0.tgz",
|
||||
"integrity": "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -75,9 +75,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-linux-64": {
|
||||
"version": "1.20260116.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260116.0.tgz",
|
||||
"integrity": "sha512-nCMy7D7BeH/feGiD7C5Z1LG19Wvs3qmHSRe3cwz6HYRQHdDXUHTjXwEVid7Vejf9QFNe3iAn49Sy/h2XY2Rqeg==",
|
||||
"version": "1.20260120.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260120.0.tgz",
|
||||
"integrity": "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -92,9 +92,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-linux-arm64": {
|
||||
"version": "1.20260116.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260116.0.tgz",
|
||||
"integrity": "sha512-Hve4ciPI69aIzwfSD12PVZJoEnKIkdR3Vd0w8rD1hDVxk75xAA65KqVYf5qW+8KOYrYkU3pg7hBTMjeyDF//IQ==",
|
||||
"version": "1.20260120.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260120.0.tgz",
|
||||
"integrity": "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -109,9 +109,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-windows-64": {
|
||||
"version": "1.20260116.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260116.0.tgz",
|
||||
"integrity": "sha512-7QA6OTXQtBdszkXw3rzxpkk1RoINZJY1ADQjF0vFNAbVXD1VEXLZnk0jc505tqARI8w/0DdVjaJszqL7K5k00w==",
|
||||
"version": "1.20260120.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260120.0.tgz",
|
||||
"integrity": "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1860,16 +1860,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/miniflare": {
|
||||
"version": "4.20260116.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260116.0.tgz",
|
||||
"integrity": "sha512-fCU1thOdiKfcauYp/gAchhesOTqTPy3K7xY6g72RiJ2xkna18QJ3Mh5sgDmnqlOEqSW9vpmYeK8vd/aqkrtlUA==",
|
||||
"version": "4.20260120.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260120.0.tgz",
|
||||
"integrity": "sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "0.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"undici": "7.18.2",
|
||||
"workerd": "1.20260116.0",
|
||||
"workerd": "1.20260120.0",
|
||||
"ws": "8.18.0",
|
||||
"youch": "4.1.0-beta.10",
|
||||
"zod": "^3.25.76"
|
||||
@@ -2374,9 +2374,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/workerd": {
|
||||
"version": "1.20260116.0",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260116.0.tgz",
|
||||
"integrity": "sha512-tVdBes3qkZKm9ntrgSDlvKzk4g2mcMp4bNM1+UgZMpTesb0x7e59vYYcKclbSNypmVkdLWpEc2TOpO0WF/rrZw==",
|
||||
"version": "1.20260120.0",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260120.0.tgz",
|
||||
"integrity": "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -2387,28 +2387,28 @@
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@cloudflare/workerd-darwin-64": "1.20260116.0",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260116.0",
|
||||
"@cloudflare/workerd-linux-64": "1.20260116.0",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260116.0",
|
||||
"@cloudflare/workerd-windows-64": "1.20260116.0"
|
||||
"@cloudflare/workerd-darwin-64": "1.20260120.0",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260120.0",
|
||||
"@cloudflare/workerd-linux-64": "1.20260120.0",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260120.0",
|
||||
"@cloudflare/workerd-windows-64": "1.20260120.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler": {
|
||||
"version": "4.59.3",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.59.3.tgz",
|
||||
"integrity": "sha512-zl+nqoGzWJ4K+NEMjy4GiaIi9ix59FkOzd7UsDb8CQADwy3li1DSNAzHty/BWYa3ZvMxr/G4pogMBb5vcSrNvQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.60.0.tgz",
|
||||
"integrity": "sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/kv-asset-handler": "0.4.2",
|
||||
"@cloudflare/unenv-preset": "2.10.0",
|
||||
"@cloudflare/unenv-preset": "2.11.0",
|
||||
"blake3-wasm": "2.1.5",
|
||||
"esbuild": "0.27.0",
|
||||
"miniflare": "4.20260116.0",
|
||||
"miniflare": "4.20260120.0",
|
||||
"path-to-regexp": "6.3.0",
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"workerd": "1.20260116.0"
|
||||
"workerd": "1.20260120.0"
|
||||
},
|
||||
"bin": {
|
||||
"wrangler": "bin/wrangler.js",
|
||||
@@ -2421,7 +2421,7 @@
|
||||
"fsevents": "~2.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260116.0"
|
||||
"@cloudflare/workers-types": "^4.20260120.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cloudflare/workers-types": {
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"db:seed:remote": "wrangler d1 execute cloud-instances-db --remote --file=./seed.sql",
|
||||
"db:migrate": "wrangler d1 execute cloud-instances-db --local --file=./migrations/002_add_composite_indexes.sql",
|
||||
"db:migrate:remote": "wrangler d1 execute cloud-instances-db --remote --file=./migrations/002_add_composite_indexes.sql",
|
||||
"db:migrate:003": "wrangler d1 execute cloud-instances-db --local --file=./migrations/003_add_retail_pricing.sql",
|
||||
"db:migrate:003:remote": "wrangler d1 execute cloud-instances-db --remote --file=./migrations/003_add_retail_pricing.sql",
|
||||
"db:query": "wrangler d1 execute cloud-instances-db --local --command"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
128
scripts/insert-anvil-data.sql
Normal file
128
scripts/insert-anvil-data.sql
Normal file
@@ -0,0 +1,128 @@
|
||||
-- Anvil 초기 데이터 삽입 스크립트
|
||||
|
||||
-- ============================================================
|
||||
-- anvil_instances: 인스턴스 타입 정의
|
||||
-- ============================================================
|
||||
INSERT INTO anvil_instances (name, display_name, category, vcpus, memory_gb, disk_gb, transfer_tb)
|
||||
VALUES
|
||||
('anvil-1g-1c', 'Basic 1GB', 'vm', 1, 1, 25, 1),
|
||||
('anvil-2g-1c', 'Basic 2GB', 'vm', 1, 2, 50, 2),
|
||||
('anvil-4g-2c', 'Standard 4GB', 'vm', 2, 4, 80, 4),
|
||||
('anvil-8g-4c', 'Standard 8GB', 'vm', 4, 8, 160, 5),
|
||||
('anvil-16g-6c', 'Pro 16GB', 'vm', 6, 16, 320, 8);
|
||||
|
||||
-- ============================================================
|
||||
-- anvil_pricing: 인스턴스별 리전별 가격 (원가 × 1.21)
|
||||
-- ============================================================
|
||||
|
||||
-- Linode 리전 (tyo1, tyo2, osa1) - Region IDs: 1, 2, 4
|
||||
INSERT INTO anvil_pricing (anvil_region_id, anvil_instance_id, hourly_price, monthly_price, hourly_price_krw, monthly_price_krw, cost_hourly, cost_monthly)
|
||||
SELECT
|
||||
r.id as anvil_region_id,
|
||||
i.id as anvil_instance_id,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 0.0091 -- $0.0075 × 1.21
|
||||
WHEN 'anvil-2g-1c' THEN 0.0218 -- $0.018 × 1.21
|
||||
WHEN 'anvil-4g-2c' THEN 0.0436 -- $0.036 × 1.21
|
||||
WHEN 'anvil-8g-4c' THEN 0.0871 -- $0.072 × 1.21
|
||||
WHEN 'anvil-16g-6c' THEN 0.1742 -- $0.144 × 1.21
|
||||
END as hourly_price,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 6 -- $5 × 1.21 = 6.05 → 6
|
||||
WHEN 'anvil-2g-1c' THEN 15 -- $12 × 1.21 = 14.52 → 15
|
||||
WHEN 'anvil-4g-2c' THEN 29 -- $24 × 1.21 = 29.04 → 29
|
||||
WHEN 'anvil-8g-4c' THEN 58 -- $48 × 1.21 = 58.08 → 58
|
||||
WHEN 'anvil-16g-6c' THEN 116 -- $96 × 1.21 = 116.16 → 116
|
||||
END as monthly_price,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 16 -- $0.0091 × 1450 = 13.2 → 16
|
||||
WHEN 'anvil-2g-1c' THEN 32 -- $0.0218 × 1450 = 31.6 → 32
|
||||
WHEN 'anvil-4g-2c' THEN 63 -- $0.0436 × 1450 = 63.2 → 63
|
||||
WHEN 'anvil-8g-4c' THEN 126 -- $0.0871 × 1450 = 126.3 → 126
|
||||
WHEN 'anvil-16g-6c' THEN 253 -- $0.1742 × 1450 = 252.6 → 253
|
||||
END as hourly_price_krw,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 10500 -- $6 × 1450 = 8700 → 10500
|
||||
WHEN 'anvil-2g-1c' THEN 26100 -- $15 × 1450 = 21750 → 26100
|
||||
WHEN 'anvil-4g-2c' THEN 52200 -- $29 × 1450 = 42050 → 52200
|
||||
WHEN 'anvil-8g-4c' THEN 104300 -- $58 × 1450 = 84100 → 104300
|
||||
WHEN 'anvil-16g-6c' THEN 208700 -- $116 × 1450 = 168200 → 208700
|
||||
END as monthly_price_krw,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 0.0075
|
||||
WHEN 'anvil-2g-1c' THEN 0.018
|
||||
WHEN 'anvil-4g-2c' THEN 0.036
|
||||
WHEN 'anvil-8g-4c' THEN 0.072
|
||||
WHEN 'anvil-16g-6c' THEN 0.144
|
||||
END as cost_hourly,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 5
|
||||
WHEN 'anvil-2g-1c' THEN 12
|
||||
WHEN 'anvil-4g-2c' THEN 24
|
||||
WHEN 'anvil-8g-4c' THEN 48
|
||||
WHEN 'anvil-16g-6c' THEN 96
|
||||
END as cost_monthly
|
||||
FROM anvil_regions r
|
||||
CROSS JOIN anvil_instances i
|
||||
WHERE r.id IN (1, 2, 4); -- Linode 리전만
|
||||
|
||||
-- Vultr 리전 (tyo3, osa2, sel1) - Region IDs: 3, 5, 6
|
||||
INSERT INTO anvil_pricing (anvil_region_id, anvil_instance_id, hourly_price, monthly_price, hourly_price_krw, monthly_price_krw, cost_hourly, cost_monthly)
|
||||
SELECT
|
||||
r.id as anvil_region_id,
|
||||
i.id as anvil_instance_id,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 0.0085 -- $0.007 × 1.21
|
||||
WHEN 'anvil-2g-1c' THEN 0.0169 -- $0.014 × 1.21
|
||||
WHEN 'anvil-4g-2c' THEN 0.0351 -- $0.029 × 1.21
|
||||
WHEN 'anvil-8g-4c' THEN 0.0726 -- $0.06 × 1.21
|
||||
WHEN 'anvil-16g-6c' THEN 0.1440 -- $0.119 × 1.21
|
||||
END as hourly_price,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 6 -- $5 × 1.21 = 6.05 → 6
|
||||
WHEN 'anvil-2g-1c' THEN 12 -- $10 × 1.21 = 12.10 → 12
|
||||
WHEN 'anvil-4g-2c' THEN 24 -- $20 × 1.21 = 24.20 → 24
|
||||
WHEN 'anvil-8g-4c' THEN 48 -- $40 × 1.21 = 48.40 → 48
|
||||
WHEN 'anvil-16g-6c' THEN 97 -- $80 × 1.21 = 96.80 → 97
|
||||
END as monthly_price,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 15 -- $0.0085 × 1450 = 12.3 → 15
|
||||
WHEN 'anvil-2g-1c' THEN 25 -- $0.0169 × 1450 = 24.5 → 25
|
||||
WHEN 'anvil-4g-2c' THEN 51 -- $0.0351 × 1450 = 50.9 → 51
|
||||
WHEN 'anvil-8g-4c' THEN 105 -- $0.0726 × 1450 = 105.3 → 105
|
||||
WHEN 'anvil-16g-6c' THEN 209 -- $0.1440 × 1450 = 208.8 → 209
|
||||
END as hourly_price_krw,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 10500 -- $6 × 1450 = 8700 → 10500
|
||||
WHEN 'anvil-2g-1c' THEN 21600 -- $12 × 1450 = 17400 → 21600
|
||||
WHEN 'anvil-4g-2c' THEN 43200 -- $24 × 1450 = 34800 → 43200
|
||||
WHEN 'anvil-8g-4c' THEN 86400 -- $48 × 1450 = 69600 → 86400
|
||||
WHEN 'anvil-16g-6c' THEN 174600 -- $97 × 1450 = 140650 → 174600
|
||||
END as monthly_price_krw,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 0.007
|
||||
WHEN 'anvil-2g-1c' THEN 0.014
|
||||
WHEN 'anvil-4g-2c' THEN 0.029
|
||||
WHEN 'anvil-8g-4c' THEN 0.06
|
||||
WHEN 'anvil-16g-6c' THEN 0.119
|
||||
END as cost_hourly,
|
||||
CASE i.name
|
||||
WHEN 'anvil-1g-1c' THEN 5
|
||||
WHEN 'anvil-2g-1c' THEN 10
|
||||
WHEN 'anvil-4g-2c' THEN 20
|
||||
WHEN 'anvil-8g-4c' THEN 40
|
||||
WHEN 'anvil-16g-6c' THEN 80
|
||||
END as cost_monthly
|
||||
FROM anvil_regions r
|
||||
CROSS JOIN anvil_instances i
|
||||
WHERE r.id IN (3, 5, 6); -- Vultr 리전만
|
||||
|
||||
-- ============================================================
|
||||
-- anvil_transfer_pricing: 추가 트래픽 가격 (모든 리전 동일)
|
||||
-- ============================================================
|
||||
INSERT INTO anvil_transfer_pricing (anvil_region_id, price_per_gb, price_per_gb_krw)
|
||||
SELECT
|
||||
id as anvil_region_id,
|
||||
0.012 as price_per_gb, -- $0.012 원가 × 1.21 = $0.01452 → $0.012 (반올림)
|
||||
21 as price_per_gb_krw -- $0.012 × 1450 = 17.4 → 21
|
||||
FROM anvil_regions;
|
||||
@@ -226,88 +226,52 @@ export const REQUEST_LIMITS = {
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// KRW Pricing Configuration
|
||||
// USD Retail Pricing Configuration
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Default KRW (Korean Won) pricing configuration
|
||||
* Default USD retail pricing configuration
|
||||
*
|
||||
* These defaults are used when environment variables are not set.
|
||||
* These defaults are used to calculate retail prices from wholesale prices.
|
||||
* Calculation formula:
|
||||
* KRW = USD × VAT (1.1) × Markup (1.1) × Exchange Rate (1450)
|
||||
* KRW = USD × 1754.5
|
||||
* Retail = Wholesale × Margin (1.1) × VAT (1.1)
|
||||
* Retail = Wholesale × 1.21
|
||||
*/
|
||||
export const KRW_PRICING_DEFAULTS = {
|
||||
export const USD_RETAIL_DEFAULTS = {
|
||||
/** Margin multiplier (10% margin) */
|
||||
MARGIN_MULTIPLIER: 1.1,
|
||||
/** VAT multiplier (10% VAT) */
|
||||
VAT_MULTIPLIER: 1.1,
|
||||
/** Markup multiplier (10% markup) */
|
||||
MARKUP_MULTIPLIER: 1.1,
|
||||
/** USD to KRW exchange rate */
|
||||
EXCHANGE_RATE: 1450,
|
||||
/** Total multiplier (margin × VAT) */
|
||||
TOTAL_MULTIPLIER: 1.21,
|
||||
} 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
|
||||
* Calculate USD retail hourly price from wholesale price
|
||||
* Applies margin and VAT
|
||||
*
|
||||
* @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)
|
||||
* @param wholesale - Wholesale hourly price in USD
|
||||
* @returns Retail price in USD, rounded to 4 decimal places
|
||||
*
|
||||
* @example
|
||||
* calculateKRWHourly(0.0075) // Returns 13 (with defaults)
|
||||
* calculateKRWHourly(0.144) // Returns 253 (with defaults)
|
||||
* calculateRetailHourly(0.0075) // Returns 0.0091 (with defaults)
|
||||
* calculateRetailHourly(0.144) // Returns 0.1742 (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);
|
||||
export function calculateRetailHourly(wholesale: number): number {
|
||||
return Math.round(wholesale * USD_RETAIL_DEFAULTS.TOTAL_MULTIPLIER * 10000) / 10000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate KRW monthly price from USD price
|
||||
* Applies VAT, markup, and exchange rate conversion
|
||||
* Calculate USD retail monthly price from wholesale price
|
||||
* Applies margin and VAT
|
||||
*
|
||||
* @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)
|
||||
* @param wholesale - Wholesale monthly price in USD
|
||||
* @returns Retail price in USD, rounded to nearest $1
|
||||
*
|
||||
* @example
|
||||
* calculateKRWMonthly(5) // Returns 8800 (with defaults)
|
||||
* calculateKRWMonthly(96) // Returns 168400 (with defaults)
|
||||
* calculateRetailMonthly(5) // Returns 6 (with defaults)
|
||||
* calculateRetailMonthly(96) // Returns 116 (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);
|
||||
export function calculateRetailMonthly(wholesale: number): number {
|
||||
return Math.round(wholesale * USD_RETAIL_DEFAULTS.TOTAL_MULTIPLIER);
|
||||
}
|
||||
|
||||
267
src/repositories/anvil-instances.ts
Normal file
267
src/repositories/anvil-instances.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Anvil Instances Repository
|
||||
* Handles CRUD operations for Anvil-branded instance specifications
|
||||
*/
|
||||
|
||||
import { BaseRepository } from './base';
|
||||
import { AnvilInstance, AnvilInstanceInput, RepositoryError, ErrorCodes } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
export class AnvilInstancesRepository extends BaseRepository<AnvilInstance> {
|
||||
protected tableName = 'anvil_instances';
|
||||
protected logger = createLogger('[AnvilInstancesRepository]');
|
||||
protected allowedColumns = [
|
||||
'name',
|
||||
'display_name',
|
||||
'category',
|
||||
'vcpus',
|
||||
'memory_gb',
|
||||
'disk_gb',
|
||||
'transfer_tb',
|
||||
'network_gbps',
|
||||
'gpu_model',
|
||||
'gpu_vram_gb',
|
||||
'active',
|
||||
];
|
||||
|
||||
/**
|
||||
* Find an instance by name (e.g., "anvil-1g-1c")
|
||||
*/
|
||||
async findByName(name: string): Promise<AnvilInstance | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_instances WHERE name = ?')
|
||||
.bind(name)
|
||||
.first<AnvilInstance>();
|
||||
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
this.logger.error('findByName failed', {
|
||||
name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
`Failed to find Anvil instance by name: ${name}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all instances by category
|
||||
*/
|
||||
async findByCategory(category: 'vm' | 'gpu' | 'g8' | 'vpu'): Promise<AnvilInstance[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_instances WHERE category = ? ORDER BY name')
|
||||
.bind(category)
|
||||
.all<AnvilInstance>();
|
||||
|
||||
return result.results;
|
||||
} catch (error) {
|
||||
this.logger.error('findByCategory failed', {
|
||||
category,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
`Failed to find Anvil instances by category: ${category}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active instances
|
||||
*/
|
||||
async findActive(category?: 'vm' | 'gpu' | 'g8' | 'vpu'): Promise<AnvilInstance[]> {
|
||||
try {
|
||||
let query = 'SELECT * FROM anvil_instances WHERE active = 1';
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
|
||||
if (category) {
|
||||
query += ' AND category = ?';
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
query += ' ORDER BY name';
|
||||
|
||||
const result = await this.db
|
||||
.prepare(query)
|
||||
.bind(...params)
|
||||
.all<AnvilInstance>();
|
||||
|
||||
return result.results;
|
||||
} catch (error) {
|
||||
this.logger.error('findActive failed', {
|
||||
category,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
'Failed to find active Anvil instances',
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search instances by resource requirements
|
||||
*/
|
||||
async searchByResources(
|
||||
minVcpus?: number,
|
||||
minMemoryGb?: number,
|
||||
minDiskGb?: number,
|
||||
category?: 'vm' | 'gpu' | 'g8' | 'vpu'
|
||||
): Promise<AnvilInstance[]> {
|
||||
try {
|
||||
const conditions: string[] = ['active = 1'];
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
|
||||
if (minVcpus !== undefined) {
|
||||
conditions.push('vcpus >= ?');
|
||||
params.push(minVcpus);
|
||||
}
|
||||
|
||||
if (minMemoryGb !== undefined) {
|
||||
conditions.push('memory_gb >= ?');
|
||||
params.push(minMemoryGb);
|
||||
}
|
||||
|
||||
if (minDiskGb !== undefined) {
|
||||
conditions.push('disk_gb >= ?');
|
||||
params.push(minDiskGb);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
conditions.push('category = ?');
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
const query = 'SELECT * FROM anvil_instances WHERE ' + conditions.join(' AND ') + ' ORDER BY memory_gb, vcpus';
|
||||
|
||||
const result = await this.db
|
||||
.prepare(query)
|
||||
.bind(...params)
|
||||
.all<AnvilInstance>();
|
||||
|
||||
return result.results;
|
||||
} catch (error) {
|
||||
this.logger.error('searchByResources failed', {
|
||||
minVcpus,
|
||||
minMemoryGb,
|
||||
minDiskGb,
|
||||
category,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
'Failed to search Anvil instances by resources',
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update instance active status
|
||||
*/
|
||||
async updateActive(id: number, active: boolean): Promise<AnvilInstance> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('UPDATE anvil_instances SET active = ? WHERE id = ? RETURNING *')
|
||||
.bind(active ? 1 : 0, id)
|
||||
.first<AnvilInstance>();
|
||||
|
||||
if (!result) {
|
||||
throw new RepositoryError(
|
||||
`Anvil instance not found: ${id}`,
|
||||
ErrorCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('updateActive failed', {
|
||||
id,
|
||||
active,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
|
||||
if (error instanceof RepositoryError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new RepositoryError(
|
||||
`Failed to update Anvil instance active status: ${id}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk upsert instances
|
||||
* Uses batch operations for efficiency
|
||||
*/
|
||||
async upsertMany(instances: AnvilInstanceInput[]): Promise<number> {
|
||||
if (instances.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const statements = instances.map((instance) => {
|
||||
return this.db.prepare(
|
||||
`INSERT INTO anvil_instances (
|
||||
name, display_name, category, vcpus, memory_gb, disk_gb,
|
||||
transfer_tb, network_gbps, gpu_model, gpu_vram_gb, active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name)
|
||||
DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
category = excluded.category,
|
||||
vcpus = excluded.vcpus,
|
||||
memory_gb = excluded.memory_gb,
|
||||
disk_gb = excluded.disk_gb,
|
||||
transfer_tb = excluded.transfer_tb,
|
||||
network_gbps = excluded.network_gbps,
|
||||
gpu_model = excluded.gpu_model,
|
||||
gpu_vram_gb = excluded.gpu_vram_gb,
|
||||
active = excluded.active`
|
||||
).bind(
|
||||
instance.name,
|
||||
instance.display_name,
|
||||
instance.category,
|
||||
instance.vcpus,
|
||||
instance.memory_gb,
|
||||
instance.disk_gb,
|
||||
instance.transfer_tb,
|
||||
instance.network_gbps,
|
||||
instance.gpu_model,
|
||||
instance.gpu_vram_gb,
|
||||
instance.active
|
||||
);
|
||||
});
|
||||
|
||||
const results = await this.executeBatch(statements);
|
||||
|
||||
const successCount = results.reduce(
|
||||
(sum, result) => sum + (result.meta.changes ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
this.logger.info('Upserted Anvil instances', { count: successCount });
|
||||
return successCount;
|
||||
} catch (error) {
|
||||
this.logger.error('upsertMany failed', {
|
||||
count: instances.length,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
'Failed to upsert Anvil instances',
|
||||
ErrorCodes.TRANSACTION_FAILED,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
210
src/repositories/anvil-pricing.ts
Normal file
210
src/repositories/anvil-pricing.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Anvil Pricing Repository
|
||||
* Handles CRUD operations for Anvil retail pricing (USD only)
|
||||
*/
|
||||
|
||||
import { BaseRepository } from './base';
|
||||
import { AnvilPricing, AnvilPricingInput, RepositoryError, ErrorCodes } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
export class AnvilPricingRepository extends BaseRepository<AnvilPricing> {
|
||||
protected tableName = 'anvil_pricing';
|
||||
protected logger = createLogger('[AnvilPricingRepository]');
|
||||
protected allowedColumns = [
|
||||
'anvil_instance_id',
|
||||
'anvil_region_id',
|
||||
'hourly_price',
|
||||
'monthly_price',
|
||||
'source_instance_id',
|
||||
];
|
||||
|
||||
constructor(db: D1Database) {
|
||||
super(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find pricing records for a specific instance
|
||||
*/
|
||||
async findByInstance(anvilInstanceId: number): Promise<AnvilPricing[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_pricing WHERE anvil_instance_id = ?')
|
||||
.bind(anvilInstanceId)
|
||||
.all<AnvilPricing>();
|
||||
|
||||
return result.results;
|
||||
} catch (error) {
|
||||
this.logger.error('findByInstance failed', {
|
||||
anvilInstanceId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
`Failed to find pricing for Anvil instance: ${anvilInstanceId}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find pricing records for a specific region
|
||||
*/
|
||||
async findByRegion(anvilRegionId: number): Promise<AnvilPricing[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_pricing WHERE anvil_region_id = ?')
|
||||
.bind(anvilRegionId)
|
||||
.all<AnvilPricing>();
|
||||
|
||||
return result.results;
|
||||
} catch (error) {
|
||||
this.logger.error('findByRegion failed', {
|
||||
anvilRegionId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
`Failed to find pricing for Anvil region: ${anvilRegionId}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific pricing record by instance and region
|
||||
*/
|
||||
async findByInstanceAndRegion(
|
||||
anvilInstanceId: number,
|
||||
anvilRegionId: number
|
||||
): Promise<AnvilPricing | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_pricing WHERE anvil_instance_id = ? AND anvil_region_id = ?')
|
||||
.bind(anvilInstanceId, anvilRegionId)
|
||||
.first<AnvilPricing>();
|
||||
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
this.logger.error('findByInstanceAndRegion failed', {
|
||||
anvilInstanceId,
|
||||
anvilRegionId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
`Failed to find pricing for Anvil instance ${anvilInstanceId} in region ${anvilRegionId}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search pricing by price range
|
||||
*/
|
||||
async searchByPriceRange(
|
||||
minHourly?: number,
|
||||
maxHourly?: number,
|
||||
minMonthly?: number,
|
||||
maxMonthly?: number
|
||||
): Promise<AnvilPricing[]> {
|
||||
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 anvil_pricing ${whereClause}`;
|
||||
|
||||
const result = await this.db
|
||||
.prepare(query)
|
||||
.bind(...params)
|
||||
.all<AnvilPricing>();
|
||||
|
||||
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 Anvil pricing by price range',
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk upsert pricing records
|
||||
* Uses batch operations for efficiency
|
||||
*/
|
||||
async upsertMany(pricing: AnvilPricingInput[]): Promise<number> {
|
||||
if (pricing.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const statements = pricing.map((price) => {
|
||||
return this.db.prepare(
|
||||
`INSERT INTO anvil_pricing (
|
||||
anvil_instance_id, anvil_region_id, hourly_price, monthly_price,
|
||||
source_instance_id
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(anvil_instance_id, anvil_region_id)
|
||||
DO UPDATE SET
|
||||
hourly_price = excluded.hourly_price,
|
||||
monthly_price = excluded.monthly_price,
|
||||
source_instance_id = excluded.source_instance_id`
|
||||
).bind(
|
||||
price.anvil_instance_id,
|
||||
price.anvil_region_id,
|
||||
price.hourly_price,
|
||||
price.monthly_price,
|
||||
price.source_instance_id
|
||||
);
|
||||
});
|
||||
|
||||
const results = await this.executeBatch(statements);
|
||||
|
||||
const successCount = results.reduce(
|
||||
(sum, result) => sum + (result.meta.changes ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
this.logger.info('Upserted Anvil pricing records', { count: successCount });
|
||||
return successCount;
|
||||
} catch (error) {
|
||||
this.logger.error('upsertMany failed', {
|
||||
count: pricing.length,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
'Failed to upsert Anvil pricing records',
|
||||
ErrorCodes.TRANSACTION_FAILED,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
163
src/repositories/anvil-regions.test.ts
Normal file
163
src/repositories/anvil-regions.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Anvil Regions Repository Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { AnvilRegionsRepository } from './anvil-regions';
|
||||
import type { AnvilRegion } from '../types';
|
||||
|
||||
const createMockD1Database = () => {
|
||||
return {
|
||||
prepare: vi.fn(),
|
||||
dump: vi.fn(),
|
||||
batch: vi.fn(),
|
||||
exec: vi.fn(),
|
||||
withSession: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const createMockD1Result = <T>(results: T[]) => ({
|
||||
results,
|
||||
success: true,
|
||||
meta: {},
|
||||
});
|
||||
|
||||
describe('AnvilRegionsRepository', () => {
|
||||
let db: D1Database;
|
||||
let repository: AnvilRegionsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockD1Database();
|
||||
repository = new AnvilRegionsRepository(db);
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find a region by name', async () => {
|
||||
const mockRegion: AnvilRegion = {
|
||||
id: 1,
|
||||
name: 'anvil-tyo1',
|
||||
display_name: 'Tokyo 1',
|
||||
country_code: 'JP',
|
||||
source_provider: 'linode',
|
||||
source_region_code: 'ap-northeast',
|
||||
source_region_id: 26,
|
||||
active: 1,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
db.prepare = () => ({
|
||||
bind: () => ({
|
||||
first: async () => mockRegion,
|
||||
}),
|
||||
}) as any;
|
||||
|
||||
const result = await repository.findByName('anvil-tyo1');
|
||||
expect(result).toEqual(mockRegion);
|
||||
});
|
||||
|
||||
it('should return null if region not found', async () => {
|
||||
db.prepare = () => ({
|
||||
bind: () => ({
|
||||
first: async () => null,
|
||||
}),
|
||||
}) as any;
|
||||
|
||||
const result = await repository.findByName('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByCountry', () => {
|
||||
it('should find all regions for a country', async () => {
|
||||
const mockRegions: AnvilRegion[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'anvil-tyo1',
|
||||
display_name: 'Tokyo 1',
|
||||
country_code: 'JP',
|
||||
source_provider: 'linode',
|
||||
source_region_code: 'ap-northeast',
|
||||
source_region_id: 26,
|
||||
active: 1,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'anvil-osa1',
|
||||
display_name: 'Osaka 1',
|
||||
country_code: 'JP',
|
||||
source_provider: 'linode',
|
||||
source_region_code: 'jp-osa',
|
||||
source_region_id: 13,
|
||||
active: 1,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
db.prepare = () => ({
|
||||
bind: () => ({
|
||||
all: async () => createMockD1Result(mockRegions),
|
||||
}),
|
||||
}) as any;
|
||||
|
||||
const result = await repository.findByCountry('JP');
|
||||
expect(result).toEqual(mockRegions);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActive', () => {
|
||||
it('should find all active regions', async () => {
|
||||
const mockRegions: AnvilRegion[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'anvil-tyo1',
|
||||
display_name: 'Tokyo 1',
|
||||
country_code: 'JP',
|
||||
source_provider: 'linode',
|
||||
source_region_code: 'ap-northeast',
|
||||
source_region_id: 26,
|
||||
active: 1,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
db.prepare = () => ({
|
||||
all: async () => createMockD1Result(mockRegions),
|
||||
}) as any;
|
||||
|
||||
const result = await repository.findActive();
|
||||
expect(result).toEqual(mockRegions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateActive', () => {
|
||||
it('should update region active status', async () => {
|
||||
const mockRegion: AnvilRegion = {
|
||||
id: 1,
|
||||
name: 'anvil-tyo1',
|
||||
display_name: 'Tokyo 1',
|
||||
country_code: 'JP',
|
||||
source_provider: 'linode',
|
||||
source_region_code: 'ap-northeast',
|
||||
source_region_id: 26,
|
||||
active: 0,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
db.prepare = () => ({
|
||||
bind: () => ({
|
||||
first: async () => mockRegion,
|
||||
}),
|
||||
}) as any;
|
||||
|
||||
const result = await repository.updateActive(1, false);
|
||||
expect(result.active).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
210
src/repositories/anvil-regions.ts
Normal file
210
src/repositories/anvil-regions.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Anvil Regions Repository
|
||||
* Handles CRUD operations for Anvil-branded regional datacenters
|
||||
*/
|
||||
|
||||
import { BaseRepository } from './base';
|
||||
import { AnvilRegion, AnvilRegionInput, RepositoryError, ErrorCodes } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
export class AnvilRegionsRepository extends BaseRepository<AnvilRegion> {
|
||||
protected tableName = 'anvil_regions';
|
||||
protected logger = createLogger('[AnvilRegionsRepository]');
|
||||
protected allowedColumns = [
|
||||
'name',
|
||||
'display_name',
|
||||
'country_code',
|
||||
'source_provider',
|
||||
'source_region_code',
|
||||
'source_region_id',
|
||||
'active',
|
||||
];
|
||||
|
||||
/**
|
||||
* Find a region by name (e.g., "anvil-tyo1")
|
||||
*/
|
||||
async findByName(name: string): Promise<AnvilRegion | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_regions WHERE name = ?')
|
||||
.bind(name)
|
||||
.first<AnvilRegion>();
|
||||
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
this.logger.error('findByName failed', {
|
||||
name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
`Failed to find Anvil region by name: ${name}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all regions for a country
|
||||
*/
|
||||
async findByCountry(countryCode: string): Promise<AnvilRegion[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_regions WHERE country_code = ? ORDER BY name')
|
||||
.bind(countryCode)
|
||||
.all<AnvilRegion>();
|
||||
|
||||
return result.results;
|
||||
} catch (error) {
|
||||
this.logger.error('findByCountry failed', {
|
||||
countryCode,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
`Failed to find Anvil regions for country: ${countryCode}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find region by source region ID
|
||||
*/
|
||||
async findBySourceRegion(sourceRegionId: number): Promise<AnvilRegion | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_regions WHERE source_region_id = ?')
|
||||
.bind(sourceRegionId)
|
||||
.first<AnvilRegion>();
|
||||
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
this.logger.error('findBySourceRegion failed', {
|
||||
sourceRegionId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
`Failed to find Anvil region by source region: ${sourceRegionId}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active regions
|
||||
*/
|
||||
async findActive(): Promise<AnvilRegion[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_regions WHERE active = 1 ORDER BY name')
|
||||
.all<AnvilRegion>();
|
||||
|
||||
return result.results;
|
||||
} catch (error) {
|
||||
this.logger.error('findActive failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
'Failed to find active Anvil regions',
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update region active status
|
||||
*/
|
||||
async updateActive(id: number, active: boolean): Promise<AnvilRegion> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('UPDATE anvil_regions SET active = ? WHERE id = ? RETURNING *')
|
||||
.bind(active ? 1 : 0, id)
|
||||
.first<AnvilRegion>();
|
||||
|
||||
if (!result) {
|
||||
throw new RepositoryError(
|
||||
`Anvil region not found: ${id}`,
|
||||
ErrorCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('updateActive failed', {
|
||||
id,
|
||||
active,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
|
||||
if (error instanceof RepositoryError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new RepositoryError(
|
||||
`Failed to update Anvil region active status: ${id}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk upsert regions
|
||||
* Uses batch operations for efficiency
|
||||
*/
|
||||
async upsertMany(regions: AnvilRegionInput[]): Promise<number> {
|
||||
if (regions.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const statements = regions.map((region) => {
|
||||
return this.db.prepare(
|
||||
`INSERT INTO anvil_regions (
|
||||
name, display_name, country_code, source_provider,
|
||||
source_region_code, source_region_id, active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name)
|
||||
DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
country_code = excluded.country_code,
|
||||
source_provider = excluded.source_provider,
|
||||
source_region_code = excluded.source_region_code,
|
||||
source_region_id = excluded.source_region_id,
|
||||
active = excluded.active`
|
||||
).bind(
|
||||
region.name,
|
||||
region.display_name,
|
||||
region.country_code,
|
||||
region.source_provider,
|
||||
region.source_region_code,
|
||||
region.source_region_id,
|
||||
region.active
|
||||
);
|
||||
});
|
||||
|
||||
const results = await this.executeBatch(statements);
|
||||
|
||||
const successCount = results.reduce(
|
||||
(sum, result) => sum + (result.meta.changes ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
this.logger.info('Upserted Anvil regions', { count: successCount });
|
||||
return successCount;
|
||||
} catch (error) {
|
||||
this.logger.error('upsertMany failed', {
|
||||
count: regions.length,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
'Failed to upsert Anvil regions',
|
||||
ErrorCodes.TRANSACTION_FAILED,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/repositories/anvil-transfer-pricing.ts
Normal file
91
src/repositories/anvil-transfer-pricing.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Anvil Transfer Pricing Repository
|
||||
* Handles CRUD operations for data transfer pricing per region (USD only)
|
||||
*/
|
||||
|
||||
import { BaseRepository } from './base';
|
||||
import { AnvilTransferPricing, AnvilTransferPricingInput, RepositoryError, ErrorCodes } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
export class AnvilTransferPricingRepository extends BaseRepository<AnvilTransferPricing> {
|
||||
protected tableName = 'anvil_transfer_pricing';
|
||||
protected logger = createLogger('[AnvilTransferPricingRepository]');
|
||||
protected allowedColumns = [
|
||||
'anvil_region_id',
|
||||
'price_per_gb',
|
||||
];
|
||||
|
||||
constructor(db: D1Database) {
|
||||
super(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find transfer pricing for a specific region
|
||||
*/
|
||||
async findByRegion(anvilRegionId: number): Promise<AnvilTransferPricing | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM anvil_transfer_pricing WHERE anvil_region_id = ?')
|
||||
.bind(anvilRegionId)
|
||||
.first<AnvilTransferPricing>();
|
||||
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
this.logger.error('findByRegion failed', {
|
||||
anvilRegionId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
`Failed to find transfer pricing for Anvil region: ${anvilRegionId}`,
|
||||
ErrorCodes.DATABASE_ERROR,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk upsert transfer pricing records
|
||||
* Uses batch operations for efficiency
|
||||
*/
|
||||
async upsertMany(pricing: AnvilTransferPricingInput[]): Promise<number> {
|
||||
if (pricing.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const statements = pricing.map((price) => {
|
||||
return this.db.prepare(
|
||||
`INSERT INTO anvil_transfer_pricing (
|
||||
anvil_region_id, price_per_gb
|
||||
) VALUES (?, ?)
|
||||
ON CONFLICT(anvil_region_id)
|
||||
DO UPDATE SET
|
||||
price_per_gb = excluded.price_per_gb`
|
||||
).bind(
|
||||
price.anvil_region_id,
|
||||
price.price_per_gb
|
||||
);
|
||||
});
|
||||
|
||||
const results = await this.executeBatch(statements);
|
||||
|
||||
const successCount = results.reduce(
|
||||
(sum, result) => sum + (result.meta.changes ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
this.logger.info('Upserted Anvil transfer pricing records', { count: successCount });
|
||||
return successCount;
|
||||
} catch (error) {
|
||||
this.logger.error('upsertMany failed', {
|
||||
count: pricing.length,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw new RepositoryError(
|
||||
'Failed to upsert Anvil transfer pricing records',
|
||||
ErrorCodes.TRANSACTION_FAILED,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { BaseRepository } from './base';
|
||||
import { G8Pricing, G8PricingInput, RepositoryError, ErrorCodes, Env } from '../types';
|
||||
import { G8Pricing, G8PricingInput, RepositoryError, ErrorCodes } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
|
||||
import { calculateRetailHourly, calculateRetailMonthly } from '../constants';
|
||||
|
||||
export class G8PricingRepository extends BaseRepository<G8Pricing> {
|
||||
protected tableName = 'g8_pricing';
|
||||
@@ -16,13 +16,13 @@ export class G8PricingRepository extends BaseRepository<G8Pricing> {
|
||||
'region_id',
|
||||
'hourly_price',
|
||||
'monthly_price',
|
||||
'hourly_price_krw',
|
||||
'monthly_price_krw',
|
||||
'hourly_price_retail',
|
||||
'monthly_price_retail',
|
||||
'currency',
|
||||
'available',
|
||||
];
|
||||
|
||||
constructor(db: D1Database, private env?: Env) {
|
||||
constructor(db: D1Database) {
|
||||
super(db);
|
||||
}
|
||||
|
||||
@@ -85,20 +85,21 @@ export class G8PricingRepository extends BaseRepository<G8Pricing> {
|
||||
|
||||
try {
|
||||
const statements = pricingData.map((pricing) => {
|
||||
const hourlyKrw = calculateKRWHourly(pricing.hourly_price, this.env);
|
||||
const monthlyKrw = calculateKRWMonthly(pricing.monthly_price, this.env);
|
||||
const hourlyRetail = calculateRetailHourly(pricing.hourly_price);
|
||||
const monthlyRetail = calculateRetailMonthly(pricing.monthly_price);
|
||||
|
||||
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
|
||||
hourly_price_retail, monthly_price_retail,
|
||||
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,
|
||||
hourly_price_retail = excluded.hourly_price_retail,
|
||||
monthly_price_retail = excluded.monthly_price_retail,
|
||||
currency = excluded.currency,
|
||||
available = excluded.available,
|
||||
updated_at = datetime('now')`
|
||||
@@ -107,21 +108,21 @@ export class G8PricingRepository extends BaseRepository<G8Pricing> {
|
||||
pricing.region_id,
|
||||
pricing.hourly_price,
|
||||
pricing.monthly_price,
|
||||
hourlyKrw,
|
||||
monthlyKrw,
|
||||
hourlyRetail,
|
||||
monthlyRetail,
|
||||
pricing.currency,
|
||||
pricing.available
|
||||
);
|
||||
});
|
||||
|
||||
const results = await this.db.batch(statements);
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const results = await this.executeBatch(statements);
|
||||
|
||||
this.logger.info('upsertMany completed', {
|
||||
total: pricingData.length,
|
||||
success: successCount,
|
||||
});
|
||||
const successCount = results.reduce(
|
||||
(sum, result) => sum + (result.meta.changes ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
this.logger.info('Upserted G8 pricing records', { count: successCount });
|
||||
return successCount;
|
||||
} catch (error) {
|
||||
this.logger.error('upsertMany failed', {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { BaseRepository } from './base';
|
||||
import { GpuPricing, GpuPricingInput, RepositoryError, ErrorCodes, Env } from '../types';
|
||||
import { GpuPricing, GpuPricingInput, RepositoryError, ErrorCodes } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
|
||||
import { calculateRetailHourly, calculateRetailMonthly } from '../constants';
|
||||
|
||||
export class GpuPricingRepository extends BaseRepository<GpuPricing> {
|
||||
protected tableName = 'gpu_pricing';
|
||||
@@ -16,13 +16,13 @@ export class GpuPricingRepository extends BaseRepository<GpuPricing> {
|
||||
'region_id',
|
||||
'hourly_price',
|
||||
'monthly_price',
|
||||
'hourly_price_krw',
|
||||
'monthly_price_krw',
|
||||
'hourly_price_retail',
|
||||
'monthly_price_retail',
|
||||
'currency',
|
||||
'available',
|
||||
];
|
||||
|
||||
constructor(db: D1Database, private env?: Env) {
|
||||
constructor(db: D1Database) {
|
||||
super(db);
|
||||
}
|
||||
|
||||
@@ -113,20 +113,21 @@ export class GpuPricingRepository extends BaseRepository<GpuPricing> {
|
||||
|
||||
try {
|
||||
const statements = pricingData.map((pricing) => {
|
||||
const hourlyKrw = calculateKRWHourly(pricing.hourly_price, this.env);
|
||||
const monthlyKrw = calculateKRWMonthly(pricing.monthly_price, this.env);
|
||||
const hourlyRetail = calculateRetailHourly(pricing.hourly_price);
|
||||
const monthlyRetail = calculateRetailMonthly(pricing.monthly_price);
|
||||
|
||||
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
|
||||
hourly_price_retail, monthly_price_retail,
|
||||
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,
|
||||
hourly_price_retail = excluded.hourly_price_retail,
|
||||
monthly_price_retail = excluded.monthly_price_retail,
|
||||
currency = excluded.currency,
|
||||
available = excluded.available`
|
||||
).bind(
|
||||
@@ -134,8 +135,8 @@ export class GpuPricingRepository extends BaseRepository<GpuPricing> {
|
||||
pricing.region_id,
|
||||
pricing.hourly_price,
|
||||
pricing.monthly_price,
|
||||
hourlyKrw,
|
||||
monthlyKrw,
|
||||
hourlyRetail,
|
||||
monthlyRetail,
|
||||
pricing.currency,
|
||||
pricing.available
|
||||
);
|
||||
|
||||
@@ -14,6 +14,10 @@ export { G8InstancesRepository } from './g8-instances';
|
||||
export { G8PricingRepository } from './g8-pricing';
|
||||
export { VpuInstancesRepository } from './vpu-instances';
|
||||
export { VpuPricingRepository } from './vpu-pricing';
|
||||
export { AnvilRegionsRepository } from './anvil-regions';
|
||||
export { AnvilInstancesRepository } from './anvil-instances';
|
||||
export { AnvilPricingRepository } from './anvil-pricing';
|
||||
export { AnvilTransferPricingRepository } from './anvil-transfer-pricing';
|
||||
|
||||
import { ProvidersRepository } from './providers';
|
||||
import { RegionsRepository } from './regions';
|
||||
@@ -25,6 +29,10 @@ import { G8InstancesRepository } from './g8-instances';
|
||||
import { G8PricingRepository } from './g8-pricing';
|
||||
import { VpuInstancesRepository } from './vpu-instances';
|
||||
import { VpuPricingRepository } from './vpu-pricing';
|
||||
import { AnvilRegionsRepository } from './anvil-regions';
|
||||
import { AnvilInstancesRepository } from './anvil-instances';
|
||||
import { AnvilPricingRepository } from './anvil-pricing';
|
||||
import { AnvilTransferPricingRepository } from './anvil-transfer-pricing';
|
||||
import type { Env } from '../types';
|
||||
|
||||
/**
|
||||
@@ -42,6 +50,10 @@ export class RepositoryFactory {
|
||||
private _g8Pricing?: G8PricingRepository;
|
||||
private _vpuInstances?: VpuInstancesRepository;
|
||||
private _vpuPricing?: VpuPricingRepository;
|
||||
private _anvilRegions?: AnvilRegionsRepository;
|
||||
private _anvilInstances?: AnvilInstancesRepository;
|
||||
private _anvilPricing?: AnvilPricingRepository;
|
||||
private _anvilTransferPricing?: AnvilTransferPricingRepository;
|
||||
|
||||
constructor(private _db: D1Database, private _env?: Env) {}
|
||||
|
||||
@@ -53,7 +65,7 @@ export class RepositoryFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Access to environment variables for KRW pricing configuration
|
||||
* Access to environment variables
|
||||
*/
|
||||
get env(): Env | undefined {
|
||||
return this._env;
|
||||
@@ -72,7 +84,7 @@ export class RepositoryFactory {
|
||||
}
|
||||
|
||||
get pricing(): PricingRepository {
|
||||
return this._pricing ??= new PricingRepository(this.db, this._env);
|
||||
return this._pricing ??= new PricingRepository(this.db);
|
||||
}
|
||||
|
||||
get gpuInstances(): GpuInstancesRepository {
|
||||
@@ -80,7 +92,7 @@ export class RepositoryFactory {
|
||||
}
|
||||
|
||||
get gpuPricing(): GpuPricingRepository {
|
||||
return this._gpuPricing ??= new GpuPricingRepository(this.db, this._env);
|
||||
return this._gpuPricing ??= new GpuPricingRepository(this.db);
|
||||
}
|
||||
|
||||
get g8Instances(): G8InstancesRepository {
|
||||
@@ -88,7 +100,7 @@ export class RepositoryFactory {
|
||||
}
|
||||
|
||||
get g8Pricing(): G8PricingRepository {
|
||||
return this._g8Pricing ??= new G8PricingRepository(this.db, this._env);
|
||||
return this._g8Pricing ??= new G8PricingRepository(this.db);
|
||||
}
|
||||
|
||||
get vpuInstances(): VpuInstancesRepository {
|
||||
@@ -96,6 +108,22 @@ export class RepositoryFactory {
|
||||
}
|
||||
|
||||
get vpuPricing(): VpuPricingRepository {
|
||||
return this._vpuPricing ??= new VpuPricingRepository(this.db, this._env);
|
||||
return this._vpuPricing ??= new VpuPricingRepository(this.db);
|
||||
}
|
||||
|
||||
get anvilRegions(): AnvilRegionsRepository {
|
||||
return this._anvilRegions ??= new AnvilRegionsRepository(this.db);
|
||||
}
|
||||
|
||||
get anvilInstances(): AnvilInstancesRepository {
|
||||
return this._anvilInstances ??= new AnvilInstancesRepository(this.db);
|
||||
}
|
||||
|
||||
get anvilPricing(): AnvilPricingRepository {
|
||||
return this._anvilPricing ??= new AnvilPricingRepository(this.db);
|
||||
}
|
||||
|
||||
get anvilTransferPricing(): AnvilTransferPricingRepository {
|
||||
return this._anvilTransferPricing ??= new AnvilTransferPricingRepository(this.db);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
*/
|
||||
|
||||
import { BaseRepository } from './base';
|
||||
import { Pricing, PricingInput, PriceHistory, RepositoryError, ErrorCodes, Env } from '../types';
|
||||
import { Pricing, PricingInput, PriceHistory, RepositoryError, ErrorCodes } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
|
||||
|
||||
export class PricingRepository extends BaseRepository<Pricing> {
|
||||
protected tableName = 'pricing';
|
||||
@@ -16,13 +15,11 @@ 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) {
|
||||
constructor(db: D1Database) {
|
||||
super(db);
|
||||
}
|
||||
|
||||
@@ -114,20 +111,15 @@ 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,
|
||||
hourly_price_krw, monthly_price_krw, currency, available
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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(
|
||||
@@ -135,8 +127,6 @@ export class PricingRepository extends BaseRepository<Pricing> {
|
||||
price.region_id,
|
||||
price.hourly_price,
|
||||
price.monthly_price,
|
||||
hourlyKrw,
|
||||
monthlyKrw,
|
||||
price.currency,
|
||||
price.available
|
||||
);
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { BaseRepository } from './base';
|
||||
import { VpuPricing, VpuPricingInput, RepositoryError, ErrorCodes, Env } from '../types';
|
||||
import { VpuPricing, VpuPricingInput, RepositoryError, ErrorCodes } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
|
||||
import { calculateRetailHourly, calculateRetailMonthly } from '../constants';
|
||||
|
||||
export class VpuPricingRepository extends BaseRepository<VpuPricing> {
|
||||
protected tableName = 'vpu_pricing';
|
||||
@@ -16,13 +16,13 @@ export class VpuPricingRepository extends BaseRepository<VpuPricing> {
|
||||
'region_id',
|
||||
'hourly_price',
|
||||
'monthly_price',
|
||||
'hourly_price_krw',
|
||||
'monthly_price_krw',
|
||||
'hourly_price_retail',
|
||||
'monthly_price_retail',
|
||||
'currency',
|
||||
'available',
|
||||
];
|
||||
|
||||
constructor(db: D1Database, private env?: Env) {
|
||||
constructor(db: D1Database) {
|
||||
super(db);
|
||||
}
|
||||
|
||||
@@ -85,20 +85,21 @@ export class VpuPricingRepository extends BaseRepository<VpuPricing> {
|
||||
|
||||
try {
|
||||
const statements = pricingData.map((pricing) => {
|
||||
const hourlyKrw = calculateKRWHourly(pricing.hourly_price, this.env);
|
||||
const monthlyKrw = calculateKRWMonthly(pricing.monthly_price, this.env);
|
||||
const hourlyRetail = calculateRetailHourly(pricing.hourly_price);
|
||||
const monthlyRetail = calculateRetailMonthly(pricing.monthly_price);
|
||||
|
||||
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
|
||||
hourly_price_retail, monthly_price_retail,
|
||||
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,
|
||||
hourly_price_retail = excluded.hourly_price_retail,
|
||||
monthly_price_retail = excluded.monthly_price_retail,
|
||||
currency = excluded.currency,
|
||||
available = excluded.available,
|
||||
updated_at = datetime('now')`
|
||||
@@ -107,20 +108,21 @@ export class VpuPricingRepository extends BaseRepository<VpuPricing> {
|
||||
pricing.region_id,
|
||||
pricing.hourly_price,
|
||||
pricing.monthly_price,
|
||||
hourlyKrw,
|
||||
monthlyKrw,
|
||||
hourlyRetail,
|
||||
monthlyRetail,
|
||||
pricing.currency,
|
||||
pricing.available
|
||||
);
|
||||
});
|
||||
|
||||
const results = await this.db.batch(statements);
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const results = await this.executeBatch(statements);
|
||||
|
||||
this.logger.info('upsertMany completed', {
|
||||
total: pricingData.length,
|
||||
success: successCount,
|
||||
});
|
||||
const successCount = results.reduce(
|
||||
(sum, result) => sum + (result.meta.changes ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
this.logger.info('Upserted VPU pricing records', { count: successCount });
|
||||
|
||||
return successCount;
|
||||
} catch (error) {
|
||||
|
||||
@@ -64,8 +64,6 @@ 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;
|
||||
@@ -192,8 +190,6 @@ 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,
|
||||
@@ -380,8 +376,6 @@ 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,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { VultrConnector } from '../connectors/vultr';
|
||||
import { AWSConnector } from '../connectors/aws';
|
||||
import { RepositoryFactory } from '../repositories';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { calculateRetailHourly, calculateRetailMonthly } from '../constants';
|
||||
import type {
|
||||
Env,
|
||||
ProviderSyncResult,
|
||||
@@ -292,7 +293,23 @@ export class SyncOrchestrator {
|
||||
}
|
||||
this.logger.info(`${provider} → ${stage}`);
|
||||
|
||||
// Stage 8: Complete - Update provider status to success
|
||||
// Stage 8: Sync Anvil Pricing (if applicable)
|
||||
stage = 'SYNC_ANVIL_PRICING' as SyncStage;
|
||||
let anvilPricingCount = 0;
|
||||
try {
|
||||
anvilPricingCount = await this.syncAnvilPricing(provider);
|
||||
if (anvilPricingCount > 0) {
|
||||
this.logger.info(`${provider} → ${stage}`, { anvil_pricing: anvilPricingCount });
|
||||
}
|
||||
} catch (anvilError) {
|
||||
// Log error but don't fail the entire sync
|
||||
this.logger.error('Anvil pricing sync failed', {
|
||||
provider,
|
||||
error: anvilError instanceof Error ? anvilError.message : String(anvilError)
|
||||
});
|
||||
}
|
||||
|
||||
// Stage 9: Complete - Update provider status to success
|
||||
stage = SyncStage.COMPLETE;
|
||||
await this.repos.providers.updateSyncStatus(provider, 'success');
|
||||
|
||||
@@ -832,6 +849,167 @@ export class SyncOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize Anvil pricing based on source provider pricing
|
||||
*
|
||||
* Updates anvil_pricing table with retail prices calculated from source pricing
|
||||
* Formula: retail = cost × 1.21 (10% margin + 10% VAT)
|
||||
*
|
||||
* @param provider - Source provider name (linode, vultr, aws)
|
||||
* @returns Number of anvil_pricing records updated
|
||||
*/
|
||||
private async syncAnvilPricing(provider: string): Promise<number> {
|
||||
this.logger.info('Starting Anvil pricing sync', { provider });
|
||||
|
||||
try {
|
||||
// Step 1: Find all anvil_regions sourced from this provider
|
||||
const anvilRegionsResult = await this.repos.db
|
||||
.prepare('SELECT id, source_region_id FROM anvil_regions WHERE source_provider = ?')
|
||||
.bind(provider)
|
||||
.all<{ id: number; source_region_id: number }>();
|
||||
|
||||
if (!anvilRegionsResult.success || anvilRegionsResult.results.length === 0) {
|
||||
this.logger.info('No anvil_regions found for provider', { provider });
|
||||
return 0;
|
||||
}
|
||||
|
||||
const anvilRegions = anvilRegionsResult.results;
|
||||
this.logger.info('Found anvil_regions', { provider, count: anvilRegions.length });
|
||||
|
||||
// Step 2: Find all anvil_pricing records with source_instance_id
|
||||
const anvilPricingResult = await this.repos.db
|
||||
.prepare(`
|
||||
SELECT
|
||||
ap.id,
|
||||
ap.anvil_instance_id,
|
||||
ap.anvil_region_id,
|
||||
ap.source_instance_id,
|
||||
ar.source_region_id
|
||||
FROM anvil_pricing ap
|
||||
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
|
||||
WHERE ar.source_provider = ?
|
||||
AND ap.source_instance_id IS NOT NULL
|
||||
`)
|
||||
.bind(provider)
|
||||
.all<{
|
||||
id: number;
|
||||
anvil_instance_id: number;
|
||||
anvil_region_id: number;
|
||||
source_instance_id: number;
|
||||
source_region_id: number;
|
||||
}>();
|
||||
|
||||
if (!anvilPricingResult.success || anvilPricingResult.results.length === 0) {
|
||||
this.logger.info('No anvil_pricing records found with source_instance_id', { provider });
|
||||
return 0;
|
||||
}
|
||||
|
||||
const anvilPricingRecords = anvilPricingResult.results;
|
||||
this.logger.info('Found anvil_pricing records to update', {
|
||||
provider,
|
||||
count: anvilPricingRecords.length
|
||||
});
|
||||
|
||||
// Step 4: Fetch source pricing data in batch
|
||||
const sourcePricingResult = await this.repos.db
|
||||
.prepare(`
|
||||
SELECT
|
||||
instance_type_id,
|
||||
region_id,
|
||||
hourly_price,
|
||||
monthly_price
|
||||
FROM pricing
|
||||
WHERE instance_type_id IN (${anvilPricingRecords.map(() => '?').join(',')})
|
||||
AND region_id IN (${anvilPricingRecords.map(() => '?').join(',')})
|
||||
`)
|
||||
.bind(
|
||||
...anvilPricingRecords.map(r => r.source_instance_id),
|
||||
...anvilPricingRecords.map(r => r.source_region_id)
|
||||
)
|
||||
.all<{
|
||||
instance_type_id: number;
|
||||
region_id: number;
|
||||
hourly_price: number;
|
||||
monthly_price: number;
|
||||
}>();
|
||||
|
||||
if (!sourcePricingResult.success || sourcePricingResult.results.length === 0) {
|
||||
this.logger.warn('No source pricing data found', { provider });
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Step 5: Build lookup map: `${instance_type_id}_${region_id}` → pricing
|
||||
const sourcePricingMap = new Map<string, { hourly_price: number; monthly_price: number }>(
|
||||
sourcePricingResult.results.map(p => [
|
||||
`${p.instance_type_id}_${p.region_id}`,
|
||||
{ hourly_price: p.hourly_price, monthly_price: p.monthly_price }
|
||||
])
|
||||
);
|
||||
|
||||
// Step 6: Prepare update statements
|
||||
const updateStatements: D1PreparedStatement[] = [];
|
||||
|
||||
for (const record of anvilPricingRecords) {
|
||||
const lookupKey = `${record.source_instance_id}_${record.source_region_id}`;
|
||||
const sourcePricing = sourcePricingMap.get(lookupKey);
|
||||
|
||||
if (!sourcePricing) {
|
||||
this.logger.warn('Source pricing not found', {
|
||||
anvil_pricing_id: record.id,
|
||||
source_instance_id: record.source_instance_id,
|
||||
source_region_id: record.source_region_id
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate retail prices: cost × 1.21
|
||||
const hourlyPrice = calculateRetailHourly(sourcePricing.hourly_price);
|
||||
const monthlyPrice = calculateRetailMonthly(sourcePricing.monthly_price);
|
||||
|
||||
updateStatements.push(
|
||||
this.repos.db.prepare(`
|
||||
UPDATE anvil_pricing
|
||||
SET
|
||||
hourly_price = ?,
|
||||
monthly_price = ?
|
||||
WHERE id = ?
|
||||
`).bind(
|
||||
hourlyPrice,
|
||||
monthlyPrice,
|
||||
record.id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (updateStatements.length === 0) {
|
||||
this.logger.info('No anvil_pricing records to update', { provider });
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Step 7: Execute batch update
|
||||
const results = await this.repos.db.batch(updateStatements);
|
||||
const successCount = results.reduce(
|
||||
(sum, result) => sum + (result.meta?.changes ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
this.logger.info('Anvil pricing sync completed', {
|
||||
provider,
|
||||
updated: successCount,
|
||||
total: updateStatements.length
|
||||
});
|
||||
|
||||
return successCount;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Anvil pricing sync failed', {
|
||||
provider,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create connector for a specific provider
|
||||
*
|
||||
|
||||
96
src/types.ts
96
src/types.ts
@@ -88,8 +88,6 @@ 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;
|
||||
@@ -128,8 +126,6 @@ export interface GpuPricing {
|
||||
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;
|
||||
@@ -157,8 +153,6 @@ export interface G8Pricing {
|
||||
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;
|
||||
@@ -187,8 +181,6 @@ export interface VpuPricing {
|
||||
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;
|
||||
@@ -202,13 +194,13 @@ export interface VpuPricing {
|
||||
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' | 'hourly_price_krw' | 'monthly_price_krw'>;
|
||||
export type PricingInput = Omit<Pricing, 'id' | 'created_at' | 'updated_at'>;
|
||||
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 GpuPricingInput = Omit<GpuPricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw' | 'hourly_price_retail' | 'monthly_price_retail'>;
|
||||
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 G8PricingInput = Omit<G8Pricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw' | 'hourly_price_retail' | 'monthly_price_retail'>;
|
||||
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'>;
|
||||
export type VpuPricingInput = Omit<VpuPricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw' | 'hourly_price_retail' | 'monthly_price_retail'>;
|
||||
|
||||
// ============================================================
|
||||
// Error Types
|
||||
@@ -452,12 +444,6 @@ 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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -610,3 +596,77 @@ export interface RecommendationResponse {
|
||||
/** List of recommended instances (sorted by match score) */
|
||||
recommendations: InstanceRecommendation[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Anvil Product Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Anvil Region - Anvil-branded regional datacenters
|
||||
*/
|
||||
export interface AnvilRegion {
|
||||
id: number;
|
||||
name: string; // "anvil-tyo1", "anvil-sel1"
|
||||
display_name: string; // "Tokyo 1", "Seoul 1"
|
||||
country_code: string; // "JP", "KR"
|
||||
source_provider: string; // "linode", "vultr"
|
||||
source_region_code: string; // "jp-tyo-3", "nrt"
|
||||
source_region_id: number; // FK to regions.id
|
||||
active: number; // SQLite boolean (0/1)
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anvil Instance - Anvil-branded instance specifications
|
||||
*/
|
||||
export interface AnvilInstance {
|
||||
id: number;
|
||||
name: string; // "anvil-1g-1c"
|
||||
display_name: string; // "Basic 1GB"
|
||||
category: 'vm' | 'gpu' | 'g8' | 'vpu';
|
||||
vcpus: number;
|
||||
memory_gb: number;
|
||||
disk_gb: number;
|
||||
transfer_tb: number | null;
|
||||
network_gbps: number | null;
|
||||
gpu_model: string | null; // GPU-specific
|
||||
gpu_vram_gb: number | null; // GPU-specific
|
||||
active: number; // SQLite boolean (0/1)
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anvil Pricing - Anvil retail pricing (USD only)
|
||||
*/
|
||||
export interface AnvilPricing {
|
||||
id: number;
|
||||
anvil_instance_id: number;
|
||||
anvil_region_id: number;
|
||||
hourly_price: number; // Retail price (USD)
|
||||
monthly_price: number; // Retail price (USD)
|
||||
source_instance_id: number | null; // FK to source tables
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anvil Transfer Pricing - Data transfer pricing per region (USD only)
|
||||
*/
|
||||
export interface AnvilTransferPricing {
|
||||
id: number;
|
||||
anvil_region_id: number;
|
||||
price_per_gb: number; // USD/GB
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Anvil Input Types (auto-derived, excluding id and timestamps)
|
||||
// ============================================================
|
||||
|
||||
export type AnvilRegionInput = Omit<AnvilRegion, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type AnvilInstanceInput = Omit<AnvilInstance, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type AnvilPricingInput = Omit<AnvilPricing, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type AnvilTransferPricingInput = Omit<AnvilTransferPricing, 'id' | 'created_at' | 'updated_at'>;
|
||||
|
||||
@@ -21,11 +21,6 @@ CACHE_TTL_SECONDS = "300"
|
||||
LOG_LEVEL = "info"
|
||||
CORS_ORIGIN = "*"
|
||||
|
||||
# KRW Pricing Configuration (can be changed without redeployment)
|
||||
KRW_EXCHANGE_RATE = "1450"
|
||||
KRW_VAT_RATE = "1.1"
|
||||
KRW_MARKUP_RATE = "1.1"
|
||||
|
||||
# Cron Triggers
|
||||
[triggers]
|
||||
crons = [
|
||||
|
||||
Reference in New Issue
Block a user